// 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}
WebDAV Settings
Virtual Directories
Create virtual directories with filters to organize your content
`;
}
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 `
`;
}
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 `
`;
}
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 = `
Directory Filter Types
Text Filters
- Include/Exclude: Simple text inclusion/exclusion
- Starts/Ends With: Matches beginning or end of filename
- Exact Match: Match the entire filename
Regex Filters
- Regex: Use regular expressions for complex patterns
- Example:
.*\\.mkv$ matches files ending with .mkv
Size Filters
- Size Greater/Less Than: Filter by file size
- Examples: 1GB, 500MB, 2.5GB
Time Filters
- Last Added: Show only recently added content
- Examples: 24h, 7d, 30d
Negative filters (Not...) will exclude matches instead of including them.
`;
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 ? `
` : ''}
`;
}
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;
}
}
}
}