Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
432 lines
20 KiB
JavaScript
432 lines
20 KiB
JavaScript
// ========== 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: '✓', text: 'Connected' },
|
|
needs_key: { bg: '#f39c12', icon: '🔑', text: 'Needs API Key' },
|
|
not_found: { bg: 'var(--muted)', icon: '—', text: 'Not Found' },
|
|
error: { bg: 'var(--bad-fg)', icon: '✗', 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 ·
|
|
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey > 0 ? ` · <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);">✓ 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 ? '✓ Radarr' : '✗ Radarr'} ·
|
|
${cs.sonarr ? '✓ Sonarr' : '✗ Sonarr'} ·
|
|
${cs.plex ? '✓ Plex' : '✗ 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);">✓ ${escapeHtml(data.appName || 'Connected')} v${escapeHtml(data.version || '')}</span>`;
|
|
} else {
|
|
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${escapeHtml(data.error)}</span>`;
|
|
}
|
|
} catch (e) {
|
|
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${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);">✓</span>'
|
|
: '<span style="color: var(--bad-fg);">✗</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 ? '✓' : '⚠';
|
|
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);">✓</span>'
|
|
: '<span style="color: var(--bad-fg);">✗</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);
|
|
})();
|