// Configuration management for Decypharr class ConfigManager { constructor() { this.debridCount = 0; this.arrCount = 0; this.debridDirectoryCounts = {}; this.directoryFilterCounts = {}; this.refs = { configForm: document.getElementById('configForm'), loadingOverlay: document.getElementById('loadingOverlay'), debridConfigs: document.getElementById('debridConfigs'), arrConfigs: document.getElementById('arrConfigs'), addDebridBtn: document.getElementById('addDebridBtn'), addArrBtn: document.getElementById('addArrBtn') }; this.init(); } init() { this.bindEvents(); this.loadConfiguration(); this.setupMagnetHandler(); this.checkIncompleteConfig(); } checkIncompleteConfig() { const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('inco')) { const errMsg = urlParams.get('inco'); window.decypharrUtils.createToast(`Incomplete configuration: ${errMsg}`, 'warning'); } } bindEvents() { // Form submission this.refs.configForm.addEventListener('submit', (e) => this.saveConfiguration(e)); // Add buttons this.refs.addDebridBtn.addEventListener('click', () => this.addDebridConfig()); this.refs.addArrBtn.addEventListener('click', () => this.addArrConfig()); // WebDAV toggle handlers document.addEventListener('change', (e) => { if (e.target.classList.contains('useWebdav')) { this.toggleWebDAVSection(e.target); } }); } async loadConfiguration() { try { const response = await window.decypharrUtils.fetcher('/api/config'); if (!response.ok) { throw new Error('Failed to load configuration'); } const config = await response.json(); this.populateForm(config); } catch (error) { console.error('Error loading configuration:', error); window.decypharrUtils.createToast('Error loading configuration', 'error'); } } populateForm(config) { // Load general settings this.populateGeneralSettings(config); // Load debrid configs if (config.debrids && Array.isArray(config.debrids)) { config.debrids.forEach(debrid => this.addDebridConfig(debrid)); } // Load qBittorrent config this.populateQBittorrentSettings(config.qbittorrent); // Load Arr configs if (config.arrs && Array.isArray(config.arrs)) { config.arrs.forEach(arr => this.addArrConfig(arr)); } // Load repair config this.populateRepairSettings(config.repair); // Load rclone config this.populateRcloneSettings(config.rclone); } populateGeneralSettings(config) { const fields = [ 'log_level', 'url_base', 'bind_address', 'port', 'discord_webhook_url', 'min_file_size', 'max_file_size', 'remove_stalled_after' ]; fields.forEach(field => { const element = document.querySelector(`[name="${field}"]`); if (element && config[field] !== undefined) { element.value = config[field]; } }); // Handle allowed file types (array) if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) { document.querySelector('[name="allowed_file_types"]').value = config.allowed_file_types.join(', '); } } populateQBittorrentSettings(qbitConfig) { if (!qbitConfig) return; const fields = ['download_folder', 'refresh_interval', 'max_downloads', 'skip_pre_cache']; fields.forEach(field => { const element = document.querySelector(`[name="qbit.${field}"]`); if (element && qbitConfig[field] !== undefined) { if (element.type === 'checkbox') { element.checked = qbitConfig[field]; } else { element.value = qbitConfig[field]; } } }); } populateRepairSettings(repairConfig) { if (!repairConfig) return; const fields = ['enabled', 'interval', 'workers', 'zurg_url', 'strategy', 'use_webdav', 'auto_process']; fields.forEach(field => { const element = document.querySelector(`[name="repair.${field}"]`); if (element && repairConfig[field] !== undefined) { if (element.type === 'checkbox') { element.checked = repairConfig[field]; } else { element.value = repairConfig[field]; } } }); } populateRcloneSettings(rcloneConfig) { if (!rcloneConfig) return; const fields = [ 'enabled', 'mount_path', 'cache_dir', 'vfs_cache_mode', 'vfs_cache_max_size', 'vfs_cache_max_age', 'vfs_cache_poll_interval', 'vfs_read_chunk_size', 'vfs_read_chunk_size_limit', 'buffer_size', 'uid', 'gid', 'vfs_read_ahead', 'attr_timeout', 'dir_cache_time', 'poll_interval', 'no_modtime', 'no_checksum' ]; fields.forEach(field => { const element = document.querySelector(`[name="rclone.${field}"]`); if (element && rcloneConfig[field] !== undefined) { if (element.type === 'checkbox') { element.checked = rcloneConfig[field]; } else { element.value = rcloneConfig[field]; } } }); } addDebridConfig(data = {}) { const debridHtml = this.getDebridTemplate(this.debridCount, data); this.refs.debridConfigs.insertAdjacentHTML('beforeend', debridHtml); // Initialize WebDAV toggle for this debrid const newDebrid = this.refs.debridConfigs.lastElementChild; const webdavToggle = newDebrid.querySelector('.useWebdav'); if (data.use_webdav) { this.toggleWebDAVSection(webdavToggle, true); } // Populate data if provided if (Object.keys(data).length > 0) { this.populateDebridData(this.debridCount, data); } // Initialize directory management this.debridDirectoryCounts[this.debridCount] = 0; // Add directories if they exist if (data.directories) { Object.entries(data.directories).forEach(([dirName, dirData]) => { const dirIndex = this.addDirectory(this.debridCount, { name: dirName, ...dirData }); // Add filters if available if (dirData.filters) { Object.entries(dirData.filters).forEach(([filterType, filterValue]) => { this.addFilter(this.debridCount, dirIndex, filterType, filterValue); }); } }); } this.debridCount++; } populateDebridData(index, data) { Object.entries(data).forEach(([key, value]) => { const input = document.querySelector(`[name="debrid[${index}].${key}"]`); if (input) { if (input.type === 'checkbox') { input.checked = value; } else if (key === 'download_api_keys' && Array.isArray(value)) { input.value = value.join('\n'); // Apply masking to populated textarea if (input.tagName.toLowerCase() === 'textarea') { input.style.webkitTextSecurity = 'disc'; input.style.textSecurity = 'disc'; input.setAttribute('data-password-visible', 'false'); } } else { input.value = value; } } }); } getDebridTemplate(index, data = {}) { return `

Debrid Service #${index + 1}

API key for the debrid service
Multiple API keys for downloads - leave empty to use main API key
Path where debrid files are mounted
API rate limit for this service
This proxy is used for this debrid account
Create internal WebDAV server
Download uncached files
Include sample files
Preprocess RAR files
`; } toggleWebDAVSection(toggle, forceShow = false) { const debridCard = toggle.closest('.debrid-config'); const index = debridCard.dataset.index; const webdavSection = debridCard.querySelector(`#webdav-section-${index}`); const webdavFields = webdavSection.querySelectorAll('.webdav-field'); if (toggle.checked || forceShow) { webdavSection.classList.remove('hidden'); // Add required attributes to key fields webdavSection.querySelectorAll('input[name$=".torrents_refresh_interval"]').forEach(el => el.required = true); webdavSection.querySelectorAll('input[name$=".download_links_refresh_interval"]').forEach(el => el.required = true); webdavSection.querySelectorAll('input[name$=".auto_expire_links_after"]').forEach(el => el.required = true); webdavSection.querySelectorAll('input[name$=".workers"]').forEach(el => el.required = true); } else { webdavSection.classList.add('hidden'); // Remove required attributes webdavFields.forEach(field => field.required = false); } } addDirectory(debridIndex, data = {}) { if (!this.debridDirectoryCounts[debridIndex]) { this.debridDirectoryCounts[debridIndex] = 0; } const dirIndex = this.debridDirectoryCounts[debridIndex]; const container = document.getElementById(`debrid[${debridIndex}].directories`); const directoryHtml = this.getDirectoryTemplate(debridIndex, dirIndex); container.insertAdjacentHTML('beforeend', directoryHtml); // Set up tracking for filters in this directory const dirKey = `${debridIndex}-${dirIndex}`; this.directoryFilterCounts[dirKey] = 0; // Fill with directory name if provided if (data.name) { const nameInput = document.querySelector(`[name="debrid[${debridIndex}].directory[${dirIndex}].name"]`); if (nameInput) nameInput.value = data.name; } this.debridDirectoryCounts[debridIndex]++; return dirIndex; } getDirectoryTemplate(debridIndex, dirIndex) { return `
Virtual Directory
`; } addFilter(debridIndex, dirIndex, filterType, filterValue = "") { const dirKey = `${debridIndex}-${dirIndex}`; if (!this.directoryFilterCounts[dirKey]) { this.directoryFilterCounts[dirKey] = 0; } const filterIndex = this.directoryFilterCounts[dirKey]; const container = document.getElementById(`debrid[${debridIndex}].directory[${dirIndex}].filters`); if (container) { const filterHtml = this.getFilterTemplate(debridIndex, dirIndex, filterIndex, filterType); container.insertAdjacentHTML('beforeend', filterHtml); // Set filter value if provided if (filterValue) { const valueInput = container.querySelector(`[name="debrid[${debridIndex}].directory[${dirIndex}].filter[${filterIndex}].value"]`); if (valueInput) valueInput.value = filterValue; } this.directoryFilterCounts[dirKey]++; } } getFilterTemplate(debridIndex, dirIndex, filterIndex, filterType) { const filterConfig = this.getFilterConfig(filterType); return `
${filterConfig.label}
`; } getFilterConfig(filterType) { const configs = { 'include': { label: 'Include', placeholder: 'Text that should be included in filename', badgeClass: 'badge-primary' }, 'exclude': { label: 'Exclude', placeholder: 'Text that should not be in filename', badgeClass: 'badge-error' }, 'regex': { label: 'Regex Match', placeholder: 'Regular expression pattern', badgeClass: 'badge-warning' }, 'not_regex': { label: 'Regex Not Match', placeholder: 'Regular expression pattern that should not match', badgeClass: 'badge-error' }, 'exact_match': { label: 'Exact Match', placeholder: 'Exact text to match', badgeClass: 'badge-primary' }, 'not_exact_match': { label: 'Not Exact Match', placeholder: 'Exact text that should not match', badgeClass: 'badge-error' }, 'starts_with': { label: 'Starts With', placeholder: 'Text that filename starts with', badgeClass: 'badge-primary' }, 'not_starts_with': { label: 'Not Starts With', placeholder: 'Text that filename should not start with', badgeClass: 'badge-error' }, 'ends_with': { label: 'Ends With', placeholder: 'Text that filename ends with', badgeClass: 'badge-primary' }, 'not_ends_with': { label: 'Not Ends With', placeholder: 'Text that filename should not end with', badgeClass: 'badge-error' }, 'size_gt': { label: 'Size Greater Than', placeholder: 'Size in bytes, KB, MB, GB (e.g. 700MB)', badgeClass: 'badge-success' }, 'size_lt': { label: 'Size Less Than', placeholder: 'Size in bytes, KB, MB, GB (e.g. 700MB)', badgeClass: 'badge-warning' }, 'last_added': { label: 'Added in the last', placeholder: 'Time duration (e.g. 24h, 7d, 30d)', badgeClass: 'badge-info' } }; return configs[filterType] || { label: filterType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), placeholder: 'Filter value', badgeClass: 'badge-ghost' }; } showFilterHelp() { // Create and show a modal with filter help const modal = document.createElement('dialog'); modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.showModal(); // Remove modal when closed modal.addEventListener('close', () => { document.body.removeChild(modal); }); } addArrConfig(data = {}) { const arrHtml = this.getArrTemplate(this.arrCount, data); this.refs.arrConfigs.insertAdjacentHTML('beforeend', arrHtml); // Populate data if provided if (Object.keys(data).length > 0) { this.populateArrData(this.arrCount, data); } this.arrCount++; } populateArrData(index, data) { Object.entries(data).forEach(([key, value]) => { const input = document.querySelector(`[name="arr[${index}].${key}"]`); if (input) { if (input.type === 'checkbox') { input.checked = value; } else { input.value = value; } } }); } getArrTemplate(index, data = {}) { const isAutoDetected = data.source === 'auto'; return `

Arr Service #${index + 1} ${isAutoDetected ? '
Auto-detected
' : ''}

${!isAutoDetected ? ` ` : ''}
Which debrid service this Arr should prefer
`; } async saveConfiguration(e) { e.preventDefault(); // Show loading overlay this.refs.loadingOverlay.classList.remove('hidden'); try { const config = this.collectFormData(); // Validate configuration const validation = this.validateConfiguration(config); if (!validation.valid) { throw new Error(validation.errors.join('\n')); } const response = await window.decypharrUtils.fetcher('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to save configuration'); } window.decypharrUtils.createToast('Configuration saved successfully! Services are restarting...', 'success'); // Reload page after a delay to allow services to restart setTimeout(() => { window.location.reload(); }, 2000); } catch (error) { console.error('Error saving configuration:', error); window.decypharrUtils.createToast(`Error saving configuration: ${error.message}`, 'error'); this.refs.loadingOverlay.classList.add('hidden'); } } validateConfiguration(config) { const errors = []; // Validate debrid services config.debrids.forEach((debrid, index) => { if (!debrid.name || !debrid.api_key || !debrid.folder) { errors.push(`Debrid service #${index + 1}: Name, API key, and folder are required`); } }); // Validate Arr services config.arrs.forEach((arr, index) => { if (!arr.name || !arr.host) { errors.push(`Arr service #${index + 1}: Name and host are required`); } if (arr.host && !this.isValidUrl(arr.host)) { errors.push(`Arr service #${index + 1}: Invalid host URL format`); } }); // Validate repair settings if (config.repair.enabled) { if (!config.repair.interval) { errors.push('Repair interval is required when repair is enabled'); } } if (config.rclone.enabled && config.rclone.mount_path === '') { errors.push('Rclone mount path is required when Rclone is enabled'); } return { valid: errors.length === 0, errors }; } isValidUrl(string) { try { new URL(string); return true; } catch (_) { return false; } } collectFormData() { return { // General settings log_level: document.getElementById('log-level').value, url_base: document.getElementById('urlBase').value, bind_address: document.getElementById('bindAddress').value, port: document.getElementById('port').value ? document.getElementById('port').value : null, discord_webhook_url: document.getElementById('discordWebhookUrl').value, allowed_file_types: document.getElementById('allowedExtensions').value .split(',').map(ext => ext.trim()).filter(Boolean), min_file_size: document.getElementById('minFileSize').value, max_file_size: document.getElementById('maxFileSize').value, remove_stalled_after: document.getElementById('removeStalledAfter').value, // Debrid configurations debrids: this.collectDebridConfigs(), // QBittorrent configuration qbittorrent: this.collectQBittorrentConfig(), // Arr configurations arrs: this.collectArrConfigs(), // Repair configuration repair: this.collectRepairConfig(), // Rclone configuration rclone: this.collectRcloneConfig() }; } collectDebridConfigs() { const debrids = []; for (let i = 0; i < this.debridCount; i++) { const nameEl = document.querySelector(`[name="debrid[${i}].name"]`); if (!nameEl || !nameEl.closest('.debrid-config')) continue; const debrid = { name: nameEl.value, api_key: document.querySelector(`[name="debrid[${i}].api_key"]`).value, folder: document.querySelector(`[name="debrid[${i}].folder"]`).value, rate_limit: document.querySelector(`[name="debrid[${i}].rate_limit"]`).value, proxy: document.querySelector(`[name="debrid[${i}].proxy"]`).value, download_uncached: document.querySelector(`[name="debrid[${i}].download_uncached"]`).checked, unpack_rar: document.querySelector(`[name="debrid[${i}].unpack_rar"]`).checked, add_samples: document.querySelector(`[name="debrid[${i}].add_samples"]`).checked, use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).checked }; // Handle download API keys const downloadKeysTextarea = document.querySelector(`[name="debrid[${i}].download_api_keys"]`); if (downloadKeysTextarea && downloadKeysTextarea.value.trim()) { debrid.download_api_keys = downloadKeysTextarea.value .split('\n') .map(key => key.trim()) .filter(key => key.length > 0); } // Add WebDAV specific properties if enabled if (debrid.use_webdav) { debrid.torrents_refresh_interval = document.querySelector(`[name="debrid[${i}].torrents_refresh_interval"]`).value; debrid.download_links_refresh_interval = document.querySelector(`[name="debrid[${i}].download_links_refresh_interval"]`).value; debrid.auto_expire_links_after = document.querySelector(`[name="debrid[${i}].auto_expire_links_after"]`).value; debrid.folder_naming = document.querySelector(`[name="debrid[${i}].folder_naming"]`).value; debrid.workers = parseInt(document.querySelector(`[name="debrid[${i}].workers"]`).value); debrid.rc_url = document.querySelector(`[name="debrid[${i}].rc_url"]`).value; debrid.rc_user = document.querySelector(`[name="debrid[${i}].rc_user"]`).value; debrid.rc_pass = document.querySelector(`[name="debrid[${i}].rc_pass"]`).value; debrid.rc_refresh_dirs = document.querySelector(`[name="debrid[${i}].rc_refresh_dirs"]`).value; debrid.serve_from_rclone = document.querySelector(`[name="debrid[${i}].serve_from_rclone"]`).checked; // Collect virtual directories debrid.directories = {}; const dirCount = this.debridDirectoryCounts[i] || 0; for (let j = 0; j < dirCount; j++) { const nameInput = document.querySelector(`[name="debrid[${i}].directory[${j}].name"]`); if (nameInput && nameInput.value && nameInput.closest('.directory-item')) { const dirName = nameInput.value; debrid.directories[dirName] = { filters: {} }; // Collect filters for this directory const dirKey = `${i}-${j}`; const filterCount = this.directoryFilterCounts[dirKey] || 0; for (let k = 0; k < filterCount; k++) { const filterTypeInput = document.querySelector(`[name="debrid[${i}].directory[${j}].filter[${k}].type"]`); const filterValueInput = document.querySelector(`[name="debrid[${i}].directory[${j}].filter[${k}].value"]`); if (filterTypeInput && filterValueInput && filterValueInput.value && filterValueInput.closest('.filter-item')) { const filterType = filterTypeInput.value; debrid.directories[dirName].filters[filterType] = filterValueInput.value; } } } } } if (debrid.name && debrid.api_key) { debrids.push(debrid); } } return debrids; } collectQBittorrentConfig() { return { download_folder: document.querySelector('[name="qbit.download_folder"]').value, refresh_interval: parseInt(document.querySelector('[name="qbit.refresh_interval"]').value) || 30, max_downloads: parseInt(document.querySelector('[name="qbit.max_downloads"]').value) || 0, skip_pre_cache: document.querySelector('[name="qbit.skip_pre_cache"]').checked }; } collectArrConfigs() { const arrs = []; for (let i = 0; i < this.arrCount; i++) { const nameEl = document.querySelector(`[name="arr[${i}].name"]`); if (!nameEl || !nameEl.closest('.arr-config')) continue; const arr = { name: nameEl.value, host: document.querySelector(`[name="arr[${i}].host"]`).value, token: document.querySelector(`[name="arr[${i}].token"]`).value, cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked, skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked, download_uncached: document.querySelector(`[name="arr[${i}].download_uncached"]`).checked, selected_debrid: document.querySelector(`[name="arr[${i}].selected_debrid"]`).value, source: document.querySelector(`[name="arr[${i}].source"]`).value }; if (arr.name && arr.host) { arrs.push(arr); } } return arrs; } collectRepairConfig() { return { enabled: document.querySelector('[name="repair.enabled"]').checked, interval: document.querySelector('[name="repair.interval"]').value, zurg_url: document.querySelector('[name="repair.zurg_url"]').value, strategy: document.querySelector('[name="repair.strategy"]').value, workers: parseInt(document.querySelector('[name="repair.workers"]').value) || 1, use_webdav: document.querySelector('[name="repair.use_webdav"]').checked, auto_process: document.querySelector('[name="repair.auto_process"]').checked }; } collectRcloneConfig() { const getElementValue = (name, defaultValue = '') => { const element = document.querySelector(`[name="rclone.${name}"]`); if (!element) return defaultValue; if (element.type === 'checkbox') { return element.checked; } else if (element.type === 'number') { const val = parseInt(element.value); return isNaN(val) ? 0 : val; } else { return element.value || defaultValue; } }; return { enabled: getElementValue('enabled', false), mount_path: getElementValue('mount_path'), buffer_size: getElementValue('buffer_size'), cache_dir: getElementValue('cache_dir'), vfs_cache_mode: getElementValue('vfs_cache_mode', 'off'), vfs_cache_max_age: getElementValue('vfs_cache_max_age', '1h'), vfs_cache_max_size: getElementValue('vfs_cache_max_size'), vfs_cache_poll_interval: getElementValue('vfs_cache_poll_interval', '1m'), vfs_read_chunk_size: getElementValue('vfs_read_chunk_size', '128M'), vfs_read_chunk_size_limit: getElementValue('vfs_read_chunk_size_limit', 'off'), uid: getElementValue('uid', 0), gid: getElementValue('gid', 0), vfs_read_ahead: getElementValue('vfs_read_ahead', '128k'), attr_timeout: getElementValue('attr_timeout', '1s'), dir_cache_time: getElementValue('dir_cache_time', '5m'), no_modtime: getElementValue('no_modtime', false), no_checksum: getElementValue('no_checksum', false), }; } setupMagnetHandler() { window.registerMagnetLinkHandler = () => { if ('registerProtocolHandler' in navigator) { try { navigator.registerProtocolHandler( 'magnet', `${window.location.origin}${window.urlBase}download?magnet=%s`, 'Decypharr' ); localStorage.setItem('magnetHandler', 'true'); const btn = document.getElementById('registerMagnetLink'); btn.innerHTML = 'Magnet Handler Registered'; btn.classList.remove('btn-primary'); btn.classList.add('btn-success'); btn.disabled = true; window.decypharrUtils.createToast('Magnet link handler registered successfully'); } catch (error) { console.error('Failed to register magnet link handler:', error); window.decypharrUtils.createToast('Failed to register magnet link handler', 'error'); } } else { window.decypharrUtils.createToast('Magnet link registration not supported in this browser', 'warning'); } }; // Check if already registered if (localStorage.getItem('magnetHandler') === 'true') { const btn = document.getElementById('registerMagnetLink'); if (btn) { btn.innerHTML = 'Magnet Handler Registered'; btn.classList.remove('btn-primary'); btn.classList.add('btn-success'); btn.disabled = true; } } } }