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:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
// ========== SMART ARR CONNECT ==========
(function() {
injectModal('arr-setup-modal', `<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>🎬 Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
Auto-discover and connect your entire media stack.
</p>
<!-- Phase 1: Detection -->
<div id="smart-phase-detect">
<div style="text-align: center; padding: 20px;">
<span class="brand-spinner" style="margin-bottom: 12px; display: inline-block;"></span>
<div style="color: var(--muted); font-size: 0.9rem;">Scanning for services...</div>
</div>
<div id="smart-detect-results" style="display: none;"></div>
</div>
<!-- Phase 2: Credential Input (only for services needing keys) -->
<div id="smart-phase-credentials" style="display: none;">
<h4 class="heading-accent-section">Enter Missing API Keys</h4>
<div id="smart-credential-inputs"></div>
<!-- Connection Options -->
<div style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 10px; font-size: 0.85rem; color: var(--accent);">Connection Options</h4>
<label class="option-label">
<input type="checkbox" id="smart-opt-seerr" checked class="checkbox-sm" />
Configure Seerr with Radarr + Sonarr
</label>
<label class="option-label">
<input type="checkbox" id="smart-opt-plex" checked class="checkbox-sm" />
Connect Plex to Seerr
</label>
<label class="option-label">
<input type="checkbox" id="smart-opt-prowlarr" checked class="checkbox-sm" />
Connect Prowlarr to Radarr + Sonarr
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem;">
<input type="checkbox" id="smart-opt-save" checked class="checkbox-sm" />
Save API keys for one-click reconnect
</label>
</div>
<button id="smart-connect-btn" style="width: 100%; margin-top: 16px; padding: 12px; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none; color: white; font-weight: 600; font-size: 1rem; border-radius: 8px; cursor: pointer;">
Smart Connect
</button>
</div>
<!-- Phase 3: Connection Progress -->
<div id="smart-phase-progress" style="display: none;">
<h4 class="heading-accent-section">Connecting Everything...</h4>
<div id="smart-progress-steps" style="display: flex; flex-direction: column; gap: 6px;"></div>
</div>
<!-- Phase 4: Results -->
<div id="smart-phase-results" style="display: none;">
<div id="smart-results-content"></div>
<div id="smart-plex-libraries" style="display: none; margin-top: 12px;"></div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button id="smart-retry-btn" style="display: none; flex: 1; padding: 10px; background: #f39c12; border: none; color: white; font-weight: 500; border-radius: 8px; cursor: pointer;">
Retry Failed Steps
</button>
<a id="smart-open-seerr" href="#" target="_blank" rel="noopener noreferrer"
style="flex: 1; padding: 10px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; text-decoration: none; text-align: center;">
Open Seerr
</a>
</div>
</div>
<!-- Help Text -->
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px;">
<strong>Where to find API keys:</strong><br>
Radarr/Sonarr/Prowlarr: Settings -> General -> Security -> API Key
</div>
<!-- Close Button -->
<div class="weather-modal-buttons modal-footer-bar">
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('arr-setup-modal');
const openBtn = document.getElementById('arr-setup-btn');
const cancelBtn = document.getElementById('arr-setup-cancel');
const connectBtn = document.getElementById('smart-connect-btn');
// Phase elements
const phaseDetect = document.getElementById('smart-phase-detect');
const phaseCredentials = document.getElementById('smart-phase-credentials');
const phaseProgress = document.getElementById('smart-phase-progress');
const phaseResults = document.getElementById('smart-phase-results');
const detectResults = document.getElementById('smart-detect-results');
const credentialInputs = document.getElementById('smart-credential-inputs');
const progressSteps = document.getElementById('smart-progress-steps');
const resultsContent = document.getElementById('smart-results-content');
const plexLibraries = document.getElementById('smart-plex-libraries');
const retryBtn = document.getElementById('smart-retry-btn');
let detectedData = null; // Store detection results for smart-connect
const serviceIcons = { plex: '🎬', radarr: '🎬', sonarr: '📺', prowlarr: '🔍', seerr: '📋' };
const serviceLabels = { plex: 'Plex', radarr: 'Radarr (Movies)', sonarr: 'Sonarr (TV)', prowlarr: 'Prowlarr (Indexers)', seerr: 'Seerr' };
function showPhase(phase) {
phaseDetect.style.display = phase === 'detect' ? 'block' : 'none';
phaseCredentials.style.display = phase === 'credentials' ? 'block' : 'none';
phaseProgress.style.display = phase === 'progress' ? 'block' : 'none';
phaseResults.style.display = phase === 'results' ? 'block' : 'none';
}
function statusBadge(status) {
const colors = {
connected: { bg: 'var(--ok-fg)', icon: '&#10003;', text: 'Connected' },
needs_key: { bg: '#f39c12', icon: '&#128273;', text: 'Needs API Key' },
not_found: { bg: 'var(--muted)', icon: '&mdash;', text: 'Not Found' },
error: { bg: 'var(--bad-fg)', icon: '&#10007;', text: 'Error' }
};
const c = colors[status] || colors.not_found;
return `<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${c.bg} 20%, transparent); color: ${c.bg};">${c.icon} ${c.text}</span>`;
}
// Phase 1: Smart Detection
async function smartDetect() {
showPhase('detect');
detectResults.style.display = 'none';
try {
const response = await fetch('/api/v1/arr/smart-detect');
detectedData = await response.json();
if (!detectedData.success) {
detectResults.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(detectedData.error)}</div>`;
detectResults.style.display = 'block';
return;
}
// Render detection results
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
for (const [svc, info] of Object.entries(detectedData.services)) {
const icon = serviceIcons[svc] || '📦';
const label = serviceLabels[svc] || svc;
const source = info.source ? `<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(info.source)}</span>` : '';
const version = info.version ? `<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(info.version)}</span>` : '';
const keySaved = (info.hasApiKey || info.hasToken) && info.status === 'connected'
? '<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>' : '';
html += `<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${icon}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${label}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${source} ${version} ${keySaved}
</div>
</div>
${statusBadge(info.status)}
</div>`;
}
html += '</div>';
// Summary
const s = detectedData.summary;
html += `<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected + (5 - s.totalDetected)))} services detected &middot;
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey > 0 ? ` &middot; <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>` : ''}
</div>`;
detectResults.innerHTML = html;
detectResults.style.display = 'block';
// Build credential inputs for services that need keys
buildCredentialInputs(detectedData);
// Auto-advance after a short delay
setTimeout(() => {
showPhase('credentials');
}, 800);
} catch (e) {
detectResults.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(e.message)}</div>`;
detectResults.style.display = 'block';
}
}
// Build credential input fields
function buildCredentialInputs(data) {
let html = '';
const services = data.services;
const arrServices = ['radarr', 'sonarr', 'prowlarr'];
for (const svc of arrServices) {
const info = services[svc];
if (!info || info.status === 'not_found' && !info.url) continue;
const icon = serviceIcons[svc];
const label = serviceLabels[svc];
const isConnected = info.status === 'connected';
const borderColor = isConnected ? 'var(--ok-fg)' : 'var(--border)';
html += `<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${borderColor};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${icon}</span>
<span style="font-weight: 500;">${label}</span>
<span id="smart-${svc}-status" style="margin-left: auto; font-size: 0.75rem;">
${isConnected ? '<span style="color: var(--ok-fg);">&#10003; Connected</span>' : ''}
</span>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${svc}-url" value="${escapeHtml(info.url || '')}" placeholder="https://seedbox.com/${svc}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${svc}-key" placeholder="${isConnected ? '(saved)' : 'Paste API key'}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
</div>
<button onclick="smartTestConnection('${svc}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`;
}
// Plex status (non-editable, just shows status)
const plex = services.plex;
if (plex) {
const plexConnected = plex.status === 'connected';
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${plexConnected ? 'var(--ok-fg)' : 'var(--border)'}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">🎬</span>
<span style="font-weight: 500;">Plex</span>
${statusBadge(plex.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(plex.source || '')}</span>
</div>
</div>`;
}
// Seerr status
const seerr = services.seerr;
if (seerr) {
const seerrOk = seerr.status === 'connected';
let configuredHtml = '';
if (seerr.configuredServices) {
const cs = seerr.configuredServices;
configuredHtml = `<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${cs.radarr ? '&#10003; Radarr' : '&#10007; Radarr'} &middot;
${cs.sonarr ? '&#10003; Sonarr' : '&#10007; Sonarr'} &middot;
${cs.plex ? '&#10003; Plex' : '&#10007; Plex'}
</div>`;
}
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${seerrOk ? 'var(--ok-fg)' : 'var(--border)'};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">📋</span>
<span style="font-weight: 500;">Seerr</span>
${statusBadge(seerr.status)}
</div>
${configuredHtml}
</div>`;
}
credentialInputs.innerHTML = html;
}
// Test connection (global for onclick)
window.smartTestConnection = async function(service) {
const urlInput = document.getElementById(`smart-${service}-url`);
const keyInput = document.getElementById(`smart-${service}-key`);
const statusSpan = document.getElementById(`smart-${service}-status`);
const url = urlInput?.value.trim();
const apiKey = keyInput?.value.trim();
if (!url || !apiKey) {
statusSpan.innerHTML = '<span style="color: var(--bad-fg);">Enter URL and API key</span>';
return;
}
statusSpan.innerHTML = '<span class="brand-spinner"></span>';
try {
const response = await secureFetch('/api/v1/arr/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, url, apiKey })
});
const data = await response.json();
if (data.success) {
statusSpan.innerHTML = `<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(data.appName || 'Connected')} v${escapeHtml(data.version || '')}</span>`;
} else {
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(data.error)}</span>`;
}
} catch (e) {
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(e.message)}</span>`;
}
};
// Phase 3: Smart Connect
async function smartConnect() {
showPhase('progress');
progressSteps.innerHTML = '<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';
// Gather input
const services = {};
for (const svc of ['radarr', 'sonarr', 'prowlarr']) {
const url = document.getElementById(`smart-${svc}-url`)?.value.trim();
const apiKey = document.getElementById(`smart-${svc}-key`)?.value.trim();
if (apiKey && url) {
services[svc] = { apiKey, url };
} else if (apiKey) {
// Key provided without URL - let backend resolve
services[svc] = { apiKey };
}
// If no key entered but service was already connected, backend uses stored credentials
}
const payload = {
services: Object.keys(services).length > 0 ? services : undefined,
configurePlex: document.getElementById('smart-opt-plex')?.checked,
configureProwlarr: document.getElementById('smart-opt-prowlarr')?.checked,
configureSeerr: document.getElementById('smart-opt-seerr')?.checked,
saveCredentials: document.getElementById('smart-opt-save')?.checked
};
try {
const response = await secureFetch('/api/v1/arr/smart-connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
// Render progress steps
let stepsHtml = '';
for (const step of (data.steps || [])) {
const icon = step.status === 'success' ? '<span style="color: var(--ok-fg);">&#10003;</span>'
: '<span style="color: var(--bad-fg);">&#10007;</span>';
const detailColor = step.status === 'success' ? 'var(--muted)' : 'var(--bad-fg)';
stepsHtml += `<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${icon}
<span>${escapeHtml(step.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${detailColor};">${escapeHtml(step.details || '')}</span>
</div>`;
}
progressSteps.innerHTML = stepsHtml;
// Show results after brief delay
setTimeout(() => showResults(data), 500);
} catch (e) {
progressSteps.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(e.message)}</div>`;
}
}
// Phase 4: Results
function showResults(data) {
showPhase('results');
const s = data.summary || {};
const allGood = s.failed === 0 && s.succeeded > 0;
const headerColor = allGood ? 'var(--ok-fg)' : '#f39c12';
const headerIcon = allGood ? '&#10003;' : '&#9888;';
const headerText = allGood ? 'All Connected!' : `${escapeHtml(String(s.succeeded))}/${escapeHtml(String(s.totalSteps))} Steps Succeeded`;
let html = `<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${headerColor} 12%, transparent); border-radius: 10px; border: 1px solid ${headerColor}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${headerColor};">${headerIcon}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${headerColor};">${headerText}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(s.succeeded))} succeeded, ${escapeHtml(String(s.failed))} failed</div>
</div>`;
// Steps detail
html += '<div style="display: flex; flex-direction: column; gap: 4px;">';
for (const step of (data.steps || [])) {
const icon = step.status === 'success' ? '<span style="color: var(--ok-fg);">&#10003;</span>'
: '<span style="color: var(--bad-fg);">&#10007;</span>';
html += `<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${icon} ${escapeHtml(step.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(step.details || '')}</span>
</div>`;
}
html += '</div>';
resultsContent.innerHTML = html;
// Show retry button if any failures
retryBtn.style.display = s.failed > 0 ? 'block' : 'none';
// Fetch Plex libraries if Plex was connected
if (data.steps?.some(st => st.step.includes('Plex') && st.status === 'success')) {
fetchPlexLibraries();
}
}
async function fetchPlexLibraries() {
try {
const res = await fetch('/api/v1/plex/libraries');
const data = await res.json();
if (data.success && data.libraries?.length > 0) {
let html = `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">🎬 ${escapeHtml(data.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;
for (const lib of data.libraries) {
const typeIcon = lib.type === 'movie' ? '🎬' : lib.type === 'show' ? '📺' : '🎵';
html += `<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${typeIcon} <strong>${escapeHtml(lib.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(lib.count))} items</span>
</div>`;
}
html += '</div></div>';
plexLibraries.innerHTML = html;
plexLibraries.style.display = 'block';
}
} catch (e) {
// Ignore Plex library fetch errors
}
}
// Event listeners
openBtn?.addEventListener('click', () => {
modal.classList.add('show');
plexLibraries.style.display = 'none';
smartDetect();
});
wireModal(modal, cancelBtn);
connectBtn?.addEventListener('click', smartConnect);
retryBtn?.addEventListener('click', smartConnect);
})();