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;
|
||||
};
|
||||
129
dashcaddy-api/routes/arr/credentials.js
Normal file
129
dashcaddy-api/routes/arr/credentials.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const express = require('express');
|
||||
const { validateURL, validateToken } = require('../../input-validator');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
const router = express.Router();
|
||||
|
||||
// Store arr service credentials
|
||||
router.post('/arr/credentials', ctx.asyncHandler(async (req, res) => {
|
||||
const { service, apiKey, url, seedboxBaseUrl } = req.body;
|
||||
|
||||
if (!service || !apiKey) {
|
||||
return ctx.errorResponse(res, 400, 'Service name and API key required');
|
||||
}
|
||||
|
||||
const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
|
||||
if (!validServices.includes(service)) {
|
||||
return ctx.errorResponse(res, 400, `Invalid service. Must be one of: ${validServices.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate API key format
|
||||
try {
|
||||
validateToken(apiKey);
|
||||
} catch (e) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid API key format');
|
||||
}
|
||||
|
||||
// Validate URL if provided
|
||||
if (url) {
|
||||
try { validateURL(url); } catch (e) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid URL format');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine credential key
|
||||
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
|
||||
|
||||
// Build metadata
|
||||
const metadata = {
|
||||
service,
|
||||
source: url ? 'external' : 'local',
|
||||
url: url || null,
|
||||
storedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Test connection if URL is known
|
||||
let connectionTest = null;
|
||||
let resolvedUrl = url;
|
||||
|
||||
if (!resolvedUrl) {
|
||||
// Try to resolve URL from services.json
|
||||
try {
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
const svc = Array.isArray(services) ? services : services.services || [];
|
||||
const found = svc.find(s => s.id === service && s.isExternal);
|
||||
if (found?.externalUrl) resolvedUrl = found.externalUrl;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (resolvedUrl) {
|
||||
connectionTest = await helpers.testServiceConnection(service, resolvedUrl, apiKey);
|
||||
if (connectionTest.success) {
|
||||
metadata.lastVerified = new Date().toISOString();
|
||||
metadata.version = connectionTest.version;
|
||||
metadata.url = resolvedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the credential
|
||||
const stored = await ctx.credentialManager.store(credKey, apiKey, metadata);
|
||||
if (!stored) {
|
||||
return ctx.errorResponse(res, 500, 'Failed to store credential');
|
||||
}
|
||||
|
||||
// Optionally store seedbox base URL
|
||||
if (seedboxBaseUrl) {
|
||||
try { validateURL(seedboxBaseUrl); } catch (e) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid seedbox base URL');
|
||||
}
|
||||
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
|
||||
storedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
ctx.log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${service} API key stored`,
|
||||
connectionTest,
|
||||
url: resolvedUrl
|
||||
});
|
||||
}, 'arr-credentials-store'));
|
||||
|
||||
// List stored arr credentials (keys only, not values)
|
||||
router.get('/arr/credentials', ctx.asyncHandler(async (req, res) => {
|
||||
const services = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
|
||||
const credentials = {};
|
||||
|
||||
for (const service of services) {
|
||||
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
|
||||
const hasKey = !!(await ctx.credentialManager.retrieve(credKey));
|
||||
const metadata = await ctx.credentialManager.getMetadata(credKey);
|
||||
|
||||
credentials[service] = {
|
||||
hasKey,
|
||||
url: metadata?.url || null,
|
||||
lastVerified: metadata?.lastVerified || null,
|
||||
version: metadata?.version || null,
|
||||
source: metadata?.source || null
|
||||
};
|
||||
}
|
||||
|
||||
// Get seedbox base URL
|
||||
const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl');
|
||||
|
||||
res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null });
|
||||
}, 'arr-credentials-list'));
|
||||
|
||||
// Delete stored arr credentials
|
||||
router.delete('/arr/credentials/:service', ctx.asyncHandler(async (req, res) => {
|
||||
const { service } = req.params;
|
||||
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
|
||||
await ctx.credentialManager.delete(credKey);
|
||||
ctx.log.info('arr', 'Deleted credentials', { service });
|
||||
res.json({ success: true, message: `${service} credentials removed` });
|
||||
}, 'arr-credentials-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
283
dashcaddy-api/routes/arr/detect.js
Normal file
283
dashcaddy-api/routes/arr/detect.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const express = require('express');
|
||||
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
const router = express.Router();
|
||||
|
||||
// Detect running arr services and their configurations
|
||||
router.get('/arr/detect', ctx.asyncHandler(async (req, res) => {
|
||||
const containers = await ctx.docker.client.listContainers({ all: false });
|
||||
const detected = {
|
||||
plex: null,
|
||||
radarr: null,
|
||||
sonarr: null,
|
||||
overseerr: null,
|
||||
lidarr: null,
|
||||
prowlarr: null
|
||||
};
|
||||
|
||||
// Service detection patterns
|
||||
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))) {
|
||||
// Find the exposed port
|
||||
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(/^\//, ''),
|
||||
image: container.Image,
|
||||
port: exposedPort,
|
||||
status: container.State,
|
||||
url: helpers.getServiceUrl(containerName, exposedPort)
|
||||
};
|
||||
|
||||
// Get API key for arr services (not Plex or Overseerr)
|
||||
if (['radarr', 'sonarr', 'lidarr', 'prowlarr'].includes(service)) {
|
||||
detected[service].apiKey = await helpers.getArrApiKey(containerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Plex token if Plex is detected
|
||||
if (detected.plex) {
|
||||
detected.plex.token = await helpers.getPlexToken(detected.plex.containerName);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
services: detected,
|
||||
summary: {
|
||||
plexReady: !!(detected.plex?.token),
|
||||
radarrReady: !!(detected.radarr?.apiKey),
|
||||
sonarrReady: !!(detected.sonarr?.apiKey),
|
||||
overseerrRunning: !!detected.overseerr
|
||||
}
|
||||
});
|
||||
}, 'arr-detect'));
|
||||
|
||||
// Smart Detect: Unified discovery of all arr services
|
||||
router.get('/arr/smart-detect', ctx.asyncHandler(async (req, res) => {
|
||||
const serviceList = ['plex', 'radarr', 'sonarr', 'prowlarr', 'seerr'];
|
||||
const defaultPorts = APP_PORTS;
|
||||
const result = {};
|
||||
|
||||
// 1. Scan Docker containers
|
||||
let containers = [];
|
||||
try { containers = await ctx.docker.client.listContainers({ all: false }); } catch (e) { /* Docker not available */ }
|
||||
|
||||
const servicePatterns = ARR_SERVICES;
|
||||
|
||||
const dockerDetected = {};
|
||||
for (const container of containers) {
|
||||
const containerName = container.Names[0]?.replace(/^\//, '').toLowerCase() || '';
|
||||
const image = container.Image.toLowerCase();
|
||||
for (const [svc, 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);
|
||||
dockerDetected[svc] = {
|
||||
containerId: container.Id,
|
||||
containerName: container.Names[0]?.replace(/^\//, ''),
|
||||
port: portInfo?.PublicPort || config.port,
|
||||
status: container.State
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load services.json for external entries
|
||||
let storedServices = [];
|
||||
try {
|
||||
const data = await ctx.servicesStateManager.read();
|
||||
storedServices = Array.isArray(data) ? data : data.services || [];
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 3. Load stored credentials
|
||||
const storedCreds = {};
|
||||
const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl');
|
||||
|
||||
for (const svc of serviceList) {
|
||||
const credKey = svc === 'plex' ? 'arr.plex.token' : `arr.${svc}.apikey`;
|
||||
const apiKey = await ctx.credentialManager.retrieve(credKey);
|
||||
const metadata = await ctx.credentialManager.getMetadata(credKey);
|
||||
if (apiKey) {
|
||||
storedCreds[svc] = { apiKey, metadata };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build detection result for each service
|
||||
for (const svc of serviceList) {
|
||||
const entry = {
|
||||
status: 'not_found',
|
||||
source: null,
|
||||
url: null,
|
||||
hasApiKey: false,
|
||||
hasToken: false,
|
||||
containerId: null,
|
||||
containerName: null,
|
||||
version: null
|
||||
};
|
||||
|
||||
// Check Docker first
|
||||
if (dockerDetected[svc]) {
|
||||
const dc = dockerDetected[svc];
|
||||
entry.containerId = dc.containerId;
|
||||
entry.containerName = dc.containerName;
|
||||
entry.source = 'local';
|
||||
entry.url = `http://localhost:${dc.port}`;
|
||||
|
||||
if (svc === 'plex') {
|
||||
// Try to get Plex token from container
|
||||
try {
|
||||
const token = await helpers.getPlexToken(dc.containerName);
|
||||
if (token) {
|
||||
entry.hasToken = true;
|
||||
entry.status = 'connected';
|
||||
// Store for later use
|
||||
await ctx.credentialManager.store('arr.plex.token', token, {
|
||||
service: 'plex', source: 'local', url: entry.url,
|
||||
lastVerified: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
entry.status = 'needs_key';
|
||||
}
|
||||
} catch (e) { entry.status = 'needs_key'; }
|
||||
} else if (svc === 'seerr') {
|
||||
entry.status = 'connected';
|
||||
// Check what Overseerr has configured using Plex-based session auth
|
||||
try {
|
||||
const session = await helpers.getOverseerrSession();
|
||||
if (session) {
|
||||
entry.hasApiKey = true;
|
||||
const configuredServices = { radarr: false, sonarr: false, plex: false };
|
||||
try {
|
||||
const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, {
|
||||
headers: { 'Cookie': session.cookie },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (radarrCheck.ok) {
|
||||
const radarrSettings = await radarrCheck.json();
|
||||
configuredServices.radarr = Array.isArray(radarrSettings) ? radarrSettings.length > 0 : !!radarrSettings;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, {
|
||||
headers: { 'Cookie': session.cookie },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (sonarrCheck.ok) {
|
||||
const sonarrSettings = await sonarrCheck.json();
|
||||
configuredServices.sonarr = Array.isArray(sonarrSettings) ? sonarrSettings.length > 0 : !!sonarrSettings;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, {
|
||||
headers: { 'Cookie': session.cookie },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (plexCheck.ok) {
|
||||
const plexSettings = await plexCheck.json();
|
||||
configuredServices.plex = !!plexSettings?.ip;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
entry.configuredServices = configuredServices;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} else {
|
||||
// arr services - try to get API key from container
|
||||
try {
|
||||
const key = await helpers.getArrApiKey(dc.containerName);
|
||||
if (key) {
|
||||
entry.hasApiKey = true;
|
||||
entry.status = 'connected';
|
||||
} else {
|
||||
entry.status = storedCreds[svc] ? 'connected' : 'needs_key';
|
||||
entry.hasApiKey = !!storedCreds[svc];
|
||||
}
|
||||
} catch (e) {
|
||||
entry.status = storedCreds[svc] ? 'connected' : 'needs_key';
|
||||
entry.hasApiKey = !!storedCreds[svc];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check external services from services.json
|
||||
if (entry.status === 'not_found') {
|
||||
const externalService = storedServices.find(s => s.id === svc && s.isExternal);
|
||||
if (externalService?.externalUrl) {
|
||||
entry.source = 'external';
|
||||
entry.url = externalService.externalUrl;
|
||||
|
||||
if (storedCreds[svc]) {
|
||||
entry.hasApiKey = true;
|
||||
entry.version = storedCreds[svc].metadata?.version || null;
|
||||
// Verify connection is still good
|
||||
const test = await helpers.testServiceConnection(svc, entry.url, storedCreds[svc].apiKey);
|
||||
entry.status = test.success ? 'connected' : 'error';
|
||||
if (test.success) entry.version = test.version;
|
||||
} else {
|
||||
entry.status = 'needs_key';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check stored credentials with metadata URL
|
||||
if (entry.status === 'not_found' && storedCreds[svc]?.metadata?.url) {
|
||||
entry.source = 'stored';
|
||||
entry.url = storedCreds[svc].metadata.url;
|
||||
entry.hasApiKey = true;
|
||||
entry.version = storedCreds[svc].metadata?.version || null;
|
||||
entry.status = 'connected';
|
||||
}
|
||||
|
||||
// For plex, also check stored token
|
||||
if (svc === 'plex' && entry.status === 'not_found' && storedCreds.plex) {
|
||||
entry.hasToken = true;
|
||||
entry.source = 'stored';
|
||||
entry.url = storedCreds.plex.metadata?.url || `http://localhost:${defaultPorts.plex}`;
|
||||
entry.status = 'connected';
|
||||
}
|
||||
|
||||
result[svc] = entry;
|
||||
}
|
||||
|
||||
// 5. Detect seedbox base URL pattern
|
||||
let detectedSeedboxUrl = seedboxBaseUrl || null;
|
||||
if (!detectedSeedboxUrl) {
|
||||
const externalUrls = storedServices
|
||||
.filter(s => s.isExternal && s.externalUrl)
|
||||
.map(s => s.externalUrl);
|
||||
if (externalUrls.length > 0) {
|
||||
// Find common base URL pattern
|
||||
try {
|
||||
const url = new URL(externalUrls[0]);
|
||||
const pathParts = url.pathname.split('/').filter(p => p);
|
||||
if (pathParts.length >= 2) {
|
||||
detectedSeedboxUrl = `${url.origin}/${pathParts[0]}`;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const statuses = Object.values(result);
|
||||
const summary = {
|
||||
totalDetected: statuses.filter(s => s.status !== 'not_found').length,
|
||||
fullyConnected: statuses.filter(s => s.status === 'connected').length,
|
||||
needsApiKey: statuses.filter(s => s.status === 'needs_key').length,
|
||||
errors: statuses.filter(s => s.status === 'error').length,
|
||||
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
|
||||
};
|
||||
|
||||
res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });
|
||||
}, 'smart-detect'));
|
||||
|
||||
return router;
|
||||
};
|
||||
302
dashcaddy-api/routes/arr/helpers.js
Normal file
302
dashcaddy-api/routes/arr/helpers.js
Normal file
@@ -0,0 +1,302 @@
|
||||
const { APP_PORTS } = require('../../constants');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
|
||||
// Helper: Extract API key from arr service config.xml
|
||||
async function getArrApiKey(containerName) {
|
||||
try {
|
||||
const container = await ctx.docker.findContainer(containerName);
|
||||
if (!container) return null;
|
||||
|
||||
const dockerContainer = ctx.docker.client.getContainer(container.Id);
|
||||
const exec = await dockerContainer.exec({
|
||||
Cmd: ['cat', '/config/config.xml'],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true
|
||||
});
|
||||
|
||||
const stream = await exec.start();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
stream.on('data', chunk => data += chunk.toString());
|
||||
stream.on('end', () => {
|
||||
// Extract API key from XML
|
||||
const match = data.match(/<ApiKey>([^<]+)<\/ApiKey>/);
|
||||
resolve(match ? match[1] : null);
|
||||
});
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error('docker', 'Failed to get API key', { containerName, error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get Plex token from container or config
|
||||
async function getPlexToken(containerName) {
|
||||
try {
|
||||
const containers = await ctx.docker.client.listContainers({ all: false });
|
||||
const container = containers.find(c =>
|
||||
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
|
||||
);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
const dockerContainer = ctx.docker.client.getContainer(container.Id);
|
||||
const exec = await dockerContainer.exec({
|
||||
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true
|
||||
});
|
||||
|
||||
const stream = await exec.start();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
stream.on('data', chunk => data += chunk.toString());
|
||||
stream.on('end', () => {
|
||||
const match = data.match(/PlexOnlineToken="([^"]+)"/);
|
||||
resolve(match ? match[1] : null);
|
||||
});
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error('docker', 'Failed to get Plex token', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get container URL (internal Docker network or host)
|
||||
function getServiceUrl(containerName, port, useTailscale = false) {
|
||||
// For Docker containers, use localhost since they're on the same host
|
||||
const host = useTailscale ? (process.env.HOST_TAILSCALE_IP || 'localhost') : 'localhost';
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
// Helper: Get authenticated Seerr/Overseerr session via Plex token
|
||||
// Seerr requires Plex-based auth for admin endpoints (settings, configuration)
|
||||
async function getOverseerrSession() {
|
||||
const seerrUrl = `http://host.docker.internal:${APP_PORTS.seerr}`;
|
||||
try {
|
||||
// Try getting Plex token from running container first
|
||||
let plexToken = await getPlexToken('plex');
|
||||
|
||||
// Fall back to stored Plex token in credential manager
|
||||
if (!plexToken) {
|
||||
plexToken = await ctx.credentialManager.retrieve('arr.plex.token');
|
||||
}
|
||||
|
||||
if (!plexToken) {
|
||||
ctx.log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authenticate with Seerr via Plex token
|
||||
const authRes = await ctx.fetchT(`${seerrUrl}/api/v1/auth/plex`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ authToken: plexToken }),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!authRes.ok) {
|
||||
ctx.log.error('arr', 'Seerr Plex auth failed', { status: authRes.status });
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCookie = authRes.headers.get('set-cookie');
|
||||
if (!setCookie) {
|
||||
ctx.log.error('arr', 'No session cookie returned from Seerr');
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionCookie = setCookie.split(';')[0];
|
||||
return { cookie: sessionCookie, plexToken };
|
||||
} catch (e) {
|
||||
ctx.log.error('arr', 'Could not get Seerr session', { error: e.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Connect Plex to Overseerr
|
||||
// Uses session cookie auth (Overseerr requires Plex-based admin session for settings)
|
||||
async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) {
|
||||
// 1. Get Plex server identity (for return info)
|
||||
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
|
||||
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
if (!identityRes.ok) throw new Error('Cannot reach Plex server');
|
||||
const identity = await identityRes.json();
|
||||
const serverName = identity.MediaContainer?.friendlyName || 'Plex';
|
||||
|
||||
// 2. Configure Plex server connection in Overseerr
|
||||
// Only send writable fields — name, machineId, libraries are read-only (auto-discovered by Overseerr)
|
||||
const plexConfig = {
|
||||
ip: 'host.docker.internal',
|
||||
port: APP_PORTS.plex,
|
||||
useSsl: false
|
||||
};
|
||||
|
||||
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': sessionCookie
|
||||
},
|
||||
body: JSON.stringify(plexConfig)
|
||||
});
|
||||
|
||||
if (!configRes.ok) {
|
||||
throw new Error(`Overseerr Plex config failed: ${await configRes.text()}`);
|
||||
}
|
||||
|
||||
// 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries
|
||||
try {
|
||||
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Cookie': sessionCookie },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
} catch (e) {
|
||||
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
|
||||
}
|
||||
|
||||
// 4. Get discovered libraries
|
||||
let libraries = [];
|
||||
try {
|
||||
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
|
||||
headers: { 'Cookie': sessionCookie },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (libRes.ok) {
|
||||
const plexSettings = await libRes.json();
|
||||
libraries = plexSettings.libraries || [];
|
||||
}
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
return { success: true, libraries, serverName, machineId: identity.MediaContainer?.machineIdentifier };
|
||||
}
|
||||
|
||||
// Helper: Configure Prowlarr connected apps (Radarr/Sonarr)
|
||||
async function configureProwlarrApps(prowlarrUrl, prowlarrApiKey, apps) {
|
||||
const results = {};
|
||||
|
||||
// Check existing apps to avoid duplicates
|
||||
let existingApps = [];
|
||||
try {
|
||||
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
|
||||
headers: { 'X-Api-Key': prowlarrApiKey },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
existingApps = existingRes.ok ? await existingRes.json() : [];
|
||||
} catch (e) {
|
||||
ctx.log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message });
|
||||
}
|
||||
|
||||
for (const [appName, config] of Object.entries(apps)) {
|
||||
const implementation = appName.charAt(0).toUpperCase() + appName.slice(1); // "Radarr", "Sonarr"
|
||||
|
||||
// Skip if already configured
|
||||
if (existingApps.some(a => a.implementation === implementation)) {
|
||||
results[appName] = 'already_configured';
|
||||
continue;
|
||||
}
|
||||
|
||||
const syncCategories = appName === 'radarr'
|
||||
? [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060]
|
||||
: [5000, 5010, 5020, 5030, 5040, 5045, 5050];
|
||||
|
||||
const payload = {
|
||||
name: implementation,
|
||||
syncLevel: 'fullSync',
|
||||
implementation: implementation,
|
||||
configContract: `${implementation}Settings`,
|
||||
fields: [
|
||||
{ name: 'prowlarrUrl', value: prowlarrUrl },
|
||||
{ name: 'baseUrl', value: config.url },
|
||||
{ name: 'apiKey', value: config.apiKey },
|
||||
{ name: 'syncCategories', value: syncCategories }
|
||||
]
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': prowlarrApiKey
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
|
||||
} catch (e) {
|
||||
results[appName] = `error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Helper: Test a service connection (reusable logic)
|
||||
async function testServiceConnection(service, url, apiKey) {
|
||||
const baseUrl = url.replace(/\/+$/, '');
|
||||
let apiEndpoint, headers;
|
||||
|
||||
if (service === 'radarr' || service === 'sonarr' || service === 'lidarr') {
|
||||
apiEndpoint = `${baseUrl}/api/v3/system/status`;
|
||||
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
|
||||
} else if (service === 'prowlarr') {
|
||||
apiEndpoint = `${baseUrl}/api/v1/system/status`;
|
||||
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
|
||||
} else if (service === 'plex') {
|
||||
apiEndpoint = `${baseUrl}/identity`;
|
||||
headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' };
|
||||
} else {
|
||||
return { success: false, error: `Unknown service: ${service}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ctx.fetchT(apiEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (service === 'plex') {
|
||||
return { success: true, version: data.MediaContainer?.version, appName: 'Plex' };
|
||||
}
|
||||
return { success: true, version: data.version, appName: data.appName };
|
||||
} else if (response.status === 401) {
|
||||
return { success: false, error: 'Invalid API key' };
|
||||
} else {
|
||||
return { success: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.cause?.code === 'ECONNREFUSED') return { success: false, error: 'Connection refused' };
|
||||
if (e.name === 'AbortError') return { success: false, error: 'Connection timeout' };
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get Overseerr API key (convenience wrapper)
|
||||
async function getOverseerrApiKey() {
|
||||
const session = await getOverseerrSession();
|
||||
return session;
|
||||
}
|
||||
|
||||
return {
|
||||
getArrApiKey,
|
||||
getPlexToken,
|
||||
getServiceUrl,
|
||||
getOverseerrSession,
|
||||
getOverseerrApiKey,
|
||||
connectPlexToOverseerr,
|
||||
configureProwlarrApps,
|
||||
testServiceConnection
|
||||
};
|
||||
};
|
||||
14
dashcaddy-api/routes/arr/index.js
Normal file
14
dashcaddy-api/routes/arr/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
const helpers = require('./helpers')(ctx);
|
||||
|
||||
router.use(require('./detect')(ctx, helpers));
|
||||
router.use(require('./credentials')(ctx, helpers));
|
||||
router.use(require('./config')(ctx, helpers));
|
||||
router.use(require('./smart-connect')(ctx, helpers));
|
||||
router.use(require('./plex')(ctx, helpers));
|
||||
|
||||
return router;
|
||||
};
|
||||
76
dashcaddy-api/routes/arr/plex.js
Normal file
76
dashcaddy-api/routes/arr/plex.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
const { APP_PORTS } = require('../../constants');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
const router = express.Router();
|
||||
|
||||
// Plex Libraries endpoint
|
||||
router.get('/plex/libraries', ctx.asyncHandler(async (req, res) => {
|
||||
// Get Plex token
|
||||
let plexToken = await helpers.getPlexToken('plex');
|
||||
if (!plexToken) {
|
||||
plexToken = await ctx.credentialManager.retrieve('arr.plex.token');
|
||||
}
|
||||
|
||||
if (!plexToken) {
|
||||
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
|
||||
hint: 'Deploy Plex with a claim token or manually configure it.'
|
||||
});
|
||||
}
|
||||
|
||||
// Get Plex URL
|
||||
let plexUrl = `http://localhost:${APP_PORTS.plex}`;
|
||||
try {
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
const svcList = Array.isArray(services) ? services : services.services || [];
|
||||
const plexService = svcList.find(s => s.id === 'plex' || s.appTemplate === 'plex');
|
||||
if (plexService?.url) {
|
||||
plexUrl = plexService.url.replace('host.docker.internal', 'localhost');
|
||||
}
|
||||
} catch (e) { /* use default */ }
|
||||
|
||||
// Fetch libraries
|
||||
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, {
|
||||
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!libRes.ok) {
|
||||
return ctx.errorResponse(res, 502, `Plex returned ${libRes.status}`);
|
||||
}
|
||||
|
||||
const data = await libRes.json();
|
||||
const libraries = (data.MediaContainer?.Directory || []).map(dir => ({
|
||||
key: dir.key,
|
||||
title: dir.title,
|
||||
type: dir.type,
|
||||
count: parseInt(dir.count) || 0,
|
||||
scannedAt: dir.scannedAt
|
||||
}));
|
||||
|
||||
// Get server name
|
||||
let serverName = 'Plex';
|
||||
let version = null;
|
||||
try {
|
||||
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
|
||||
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (identityRes.ok) {
|
||||
const identity = await identityRes.json();
|
||||
serverName = identity.MediaContainer?.friendlyName || 'Plex';
|
||||
version = identity.MediaContainer?.version;
|
||||
}
|
||||
} catch (e) { /* use default */ }
|
||||
|
||||
// Store token for future use
|
||||
await ctx.credentialManager.store('arr.plex.token', plexToken, {
|
||||
service: 'plex', source: 'local', url: plexUrl,
|
||||
lastVerified: new Date().toISOString()
|
||||
});
|
||||
|
||||
res.json({ success: true, serverName, version, libraries });
|
||||
}, 'plex-libraries'));
|
||||
|
||||
return router;
|
||||
};
|
||||
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