backup-restore

This commit is contained in:
Admin9705
2025-08-23 22:12:43 -04:00
parent 4ecf4f80d3
commit ae8b1058d9
9 changed files with 2002 additions and 4 deletions

View File

@@ -0,0 +1,606 @@
/**
* Backup and Restore functionality for Huntarr
* Handles database backup creation, restoration, and management
*/
const BackupRestore = {
initialized: false,
backupSettings: {
frequency: 3,
retention: 3
},
initialize: function() {
if (this.initialized) {
console.log('[BackupRestore] Already initialized');
return;
}
console.log('[BackupRestore] Initializing backup/restore functionality');
this.bindEvents();
this.loadSettings();
this.loadBackupList();
this.updateNextBackupTime();
this.initialized = true;
console.log('[BackupRestore] Initialization complete');
},
bindEvents: function() {
// Backup frequency change
const frequencyInput = document.getElementById('backup-frequency');
if (frequencyInput) {
frequencyInput.addEventListener('change', () => {
this.backupSettings.frequency = parseInt(frequencyInput.value) || 3;
this.saveSettings();
this.updateNextBackupTime();
});
}
// Backup retention change
const retentionInput = document.getElementById('backup-retention');
if (retentionInput) {
retentionInput.addEventListener('change', () => {
this.backupSettings.retention = parseInt(retentionInput.value) || 3;
this.saveSettings();
});
}
// Create manual backup
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', () => {
this.createManualBackup();
});
}
// Restore backup selection
const restoreSelect = document.getElementById('restore-backup-select');
if (restoreSelect) {
restoreSelect.addEventListener('change', () => {
this.handleRestoreSelection();
});
}
// Restore confirmation input
const restoreConfirmation = document.getElementById('restore-confirmation');
if (restoreConfirmation) {
restoreConfirmation.addEventListener('input', () => {
this.validateRestoreConfirmation();
});
}
// Restore button
const restoreBtn = document.getElementById('restore-backup-btn');
if (restoreBtn) {
restoreBtn.addEventListener('click', () => {
this.restoreBackup();
});
}
// Delete confirmation input
const deleteConfirmation = document.getElementById('delete-confirmation');
if (deleteConfirmation) {
deleteConfirmation.addEventListener('input', () => {
this.validateDeleteConfirmation();
});
}
// Delete database button
const deleteBtn = document.getElementById('delete-database-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
this.deleteDatabase();
});
}
},
loadSettings: function() {
console.log('[BackupRestore] Loading backup settings');
fetch('./api/backup/settings')
.then(response => response.json())
.then(data => {
if (data.success) {
this.backupSettings = {
frequency: data.settings.frequency || 3,
retention: data.settings.retention || 3
};
// Update UI
const frequencyInput = document.getElementById('backup-frequency');
const retentionInput = document.getElementById('backup-retention');
if (frequencyInput) frequencyInput.value = this.backupSettings.frequency;
if (retentionInput) retentionInput.value = this.backupSettings.retention;
this.updateNextBackupTime();
}
})
.catch(error => {
console.error('[BackupRestore] Error loading settings:', error);
this.showError('Failed to load backup settings');
});
},
saveSettings: function() {
console.log('[BackupRestore] Saving backup settings', this.backupSettings);
fetch('./api/backup/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.backupSettings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('[BackupRestore] Settings saved successfully');
this.showSuccess('Backup settings saved');
} else {
throw new Error(data.error || 'Failed to save settings');
}
})
.catch(error => {
console.error('[BackupRestore] Error saving settings:', error);
this.showError('Failed to save backup settings');
});
},
loadBackupList: function() {
console.log('[BackupRestore] Loading backup list');
const listContainer = document.getElementById('backup-list-container');
const restoreSelect = document.getElementById('restore-backup-select');
if (listContainer) {
listContainer.innerHTML = '<div class="backup-list-loading"><i class="fas fa-spinner fa-spin"></i> Loading backup list...</div>';
}
if (restoreSelect) {
restoreSelect.innerHTML = '<option value="">Loading available backups...</option>';
}
fetch('./api/backup/list')
.then(response => response.json())
.then(data => {
if (data.success) {
this.renderBackupList(data.backups);
this.populateRestoreSelect(data.backups);
} else {
throw new Error(data.error || 'Failed to load backups');
}
})
.catch(error => {
console.error('[BackupRestore] Error loading backup list:', error);
if (listContainer) {
listContainer.innerHTML = '<div class="backup-list-loading">Error loading backup list</div>';
}
if (restoreSelect) {
restoreSelect.innerHTML = '<option value="">Error loading backups</option>';
}
});
},
renderBackupList: function(backups) {
const listContainer = document.getElementById('backup-list-container');
if (!listContainer) return;
if (!backups || backups.length === 0) {
listContainer.innerHTML = '<div class="backup-list-loading">No backups available</div>';
return;
}
let html = '';
backups.forEach(backup => {
const date = new Date(backup.timestamp);
const formattedDate = date.toLocaleString();
const size = this.formatFileSize(backup.size);
html += `
<div class="backup-item" data-backup-id="${backup.id}">
<div class="backup-info">
<div class="backup-name">${backup.name}</div>
<div class="backup-details">
Created: ${formattedDate} | Size: ${size} | Type: ${backup.type || 'Manual'}
</div>
</div>
<div class="backup-actions">
<button class="delete-backup-btn" onclick="BackupRestore.deleteBackup('${backup.id}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
`;
});
listContainer.innerHTML = html;
},
populateRestoreSelect: function(backups) {
const restoreSelect = document.getElementById('restore-backup-select');
if (!restoreSelect) return;
if (!backups || backups.length === 0) {
restoreSelect.innerHTML = '<option value="">No backups available</option>';
return;
}
let html = '<option value="">Select a backup to restore...</option>';
backups.forEach(backup => {
const date = new Date(backup.timestamp);
const formattedDate = date.toLocaleString();
const size = this.formatFileSize(backup.size);
html += `<option value="${backup.id}">${backup.name} - ${formattedDate} (${size})</option>`;
});
restoreSelect.innerHTML = html;
},
updateNextBackupTime: function() {
const nextBackupElement = document.getElementById('next-backup-time');
if (!nextBackupElement) return;
fetch('./api/backup/next-scheduled')
.then(response => response.json())
.then(data => {
if (data.success && data.next_backup) {
const nextDate = new Date(data.next_backup);
nextBackupElement.innerHTML = `<i class="fas fa-clock"></i> ${nextDate.toLocaleString()}`;
} else {
nextBackupElement.innerHTML = '<i class="fas fa-clock"></i> Not scheduled';
}
})
.catch(error => {
console.error('[BackupRestore] Error getting next backup time:', error);
nextBackupElement.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Error loading schedule';
});
},
createManualBackup: function() {
console.log('[BackupRestore] Creating manual backup');
const createBtn = document.getElementById('create-backup-btn');
const progressContainer = document.getElementById('backup-progress');
const progressFill = document.querySelector('.progress-fill');
const progressText = document.querySelector('.progress-text');
if (createBtn) createBtn.disabled = true;
if (progressContainer) progressContainer.style.display = 'block';
if (progressText) progressText.textContent = 'Creating backup...';
// Animate progress bar
let progress = 0;
const progressInterval = setInterval(() => {
progress += Math.random() * 15;
if (progress > 90) progress = 90;
if (progressFill) progressFill.style.width = progress + '%';
}, 200);
fetch('./api/backup/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'manual',
name: `Manual Backup ${new Date().toISOString().split('T')[0]}`
})
})
.then(response => response.json())
.then(data => {
clearInterval(progressInterval);
if (data.success) {
if (progressFill) progressFill.style.width = '100%';
if (progressText) progressText.textContent = 'Backup created successfully!';
setTimeout(() => {
if (progressContainer) progressContainer.style.display = 'none';
if (progressFill) progressFill.style.width = '0%';
}, 2000);
this.showSuccess(`Backup created successfully: ${data.backup_name}`);
this.loadBackupList(); // Refresh the list
} else {
throw new Error(data.error || 'Failed to create backup');
}
})
.catch(error => {
clearInterval(progressInterval);
console.error('[BackupRestore] Error creating backup:', error);
if (progressContainer) progressContainer.style.display = 'none';
if (progressFill) progressFill.style.width = '0%';
this.showError('Failed to create backup: ' + error.message);
})
.finally(() => {
if (createBtn) createBtn.disabled = false;
});
},
handleRestoreSelection: function() {
const restoreSelect = document.getElementById('restore-backup-select');
const confirmationGroup = document.getElementById('restore-confirmation-group');
const actionGroup = document.getElementById('restore-action-group');
if (!restoreSelect) return;
if (restoreSelect.value) {
if (confirmationGroup) confirmationGroup.style.display = 'block';
if (actionGroup) actionGroup.style.display = 'block';
} else {
if (confirmationGroup) confirmationGroup.style.display = 'none';
if (actionGroup) actionGroup.style.display = 'none';
}
this.validateRestoreConfirmation();
},
validateRestoreConfirmation: function() {
const confirmationInput = document.getElementById('restore-confirmation');
const restoreBtn = document.getElementById('restore-backup-btn');
if (!confirmationInput || !restoreBtn) return;
const isValid = confirmationInput.value.toUpperCase() === 'RESTORE';
restoreBtn.disabled = !isValid;
if (isValid) {
restoreBtn.style.background = '#e74c3c';
restoreBtn.style.cursor = 'pointer';
} else {
restoreBtn.style.background = '#6b7280';
restoreBtn.style.cursor = 'not-allowed';
}
},
restoreBackup: function() {
const restoreSelect = document.getElementById('restore-backup-select');
const confirmationInput = document.getElementById('restore-confirmation');
if (!restoreSelect || !confirmationInput) return;
const backupId = restoreSelect.value;
const confirmation = confirmationInput.value.toUpperCase();
if (!backupId || confirmation !== 'RESTORE') {
this.showError('Please select a backup and type RESTORE to confirm');
return;
}
// Final confirmation dialog
if (!confirm('This will permanently overwrite your current database. Are you absolutely sure?')) {
return;
}
console.log('[BackupRestore] Restoring backup:', backupId);
const restoreBtn = document.getElementById('restore-backup-btn');
if (restoreBtn) {
restoreBtn.disabled = true;
restoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
}
fetch('./api/backup/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
backup_id: backupId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showSuccess('Database restored successfully! Reloading page...');
// Reload the page after a short delay
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
throw new Error(data.error || 'Failed to restore backup');
}
})
.catch(error => {
console.error('[BackupRestore] Error restoring backup:', error);
this.showError('Failed to restore backup: ' + error.message);
})
.finally(() => {
if (restoreBtn) {
restoreBtn.disabled = false;
restoreBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Restore Database';
}
});
},
validateDeleteConfirmation: function() {
const confirmationInput = document.getElementById('delete-confirmation');
const actionGroup = document.getElementById('delete-action-group');
const deleteBtn = document.getElementById('delete-database-btn');
if (!confirmationInput || !actionGroup || !deleteBtn) return;
const isValid = confirmationInput.value.toLowerCase() === 'huntarr';
if (isValid) {
actionGroup.style.display = 'block';
deleteBtn.disabled = false;
deleteBtn.style.background = '#e74c3c';
deleteBtn.style.cursor = 'pointer';
} else {
actionGroup.style.display = 'none';
deleteBtn.disabled = true;
}
},
deleteDatabase: function() {
const confirmationInput = document.getElementById('delete-confirmation');
if (!confirmationInput || confirmationInput.value.toLowerCase() !== 'huntarr') {
this.showError('Please type "huntarr" to confirm database deletion');
return;
}
// Final confirmation dialog
if (!confirm('This will PERMANENTLY DELETE your entire Huntarr database. This action CANNOT be undone. Are you absolutely sure?')) {
return;
}
console.log('[BackupRestore] Deleting database');
const deleteBtn = document.getElementById('delete-database-btn');
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
}
fetch('./api/backup/delete-database', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showSuccess('Database deleted successfully! Redirecting to setup...');
// Redirect to setup after a short delay
setTimeout(() => {
window.location.href = './setup';
}, 2000);
} else {
throw new Error(data.error || 'Failed to delete database');
}
})
.catch(error => {
console.error('[BackupRestore] Error deleting database:', error);
this.showError('Failed to delete database: ' + error.message);
})
.finally(() => {
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = '<i class="fas fa-trash-alt"></i> Delete Database';
}
});
},
deleteBackup: function(backupId) {
if (!confirm('Are you sure you want to delete this backup? This action cannot be undone.')) {
return;
}
console.log('[BackupRestore] Deleting backup:', backupId);
fetch('./api/backup/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
backup_id: backupId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showSuccess('Backup deleted successfully');
this.loadBackupList(); // Refresh the list
} else {
throw new Error(data.error || 'Failed to delete backup');
}
})
.catch(error => {
console.error('[BackupRestore] Error deleting backup:', error);
this.showError('Failed to delete backup: ' + error.message);
});
},
formatFileSize: function(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
showSuccess: function(message) {
this.showNotification(message, 'success');
},
showError: function(message) {
this.showNotification(message, 'error');
},
showNotification: function(message, type) {
// Create notification element
const notification = document.createElement('div');
notification.className = `backup-notification ${type}`;
notification.innerHTML = `
<i class="fas ${type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'}"></i>
<span>${message}</span>
<button onclick="this.parentElement.remove()">×</button>
`;
// Add styles
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#10b981' : '#e74c3c'};
color: white;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10000;
display: flex;
align-items: center;
gap: 8px;
max-width: 400px;
animation: slideIn 0.3s ease;
`;
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.backup-notification button {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin-left: auto;
}
`;
document.head.appendChild(style);
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentElement) {
notification.remove();
}
}, 5000);
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Don't auto-initialize - let the main UI handle it
console.log('[BackupRestore] Module loaded');
});

View File

@@ -86,7 +86,7 @@ let huntarrUI = {
// Check which sidebar should be shown based on current section
console.log(`[huntarrUI] Initialization - current section: ${this.currentSection}`);
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' || this.currentSection === 'notifications' || this.currentSection === 'user') {
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' || this.currentSection === 'notifications' || this.currentSection === 'backup-restore' || this.currentSection === 'user') {
console.log('[huntarrUI] Initialization - showing settings sidebar');
this.showSettingsSidebar();
} else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-history') {
@@ -781,6 +781,21 @@ let huntarrUI = {
// Initialize notifications settings if not already done
this.initializeNotifications();
} else if (section === 'backup-restore' && document.getElementById('backupRestoreSection')) {
document.getElementById('backupRestoreSection').classList.add('active');
document.getElementById('backupRestoreSection').style.display = 'block';
if (document.getElementById('settingsBackupRestoreNav')) document.getElementById('settingsBackupRestoreNav').classList.add('active');
newTitle = 'Backup / Restore';
this.currentSection = 'backup-restore';
// Switch to Settings sidebar for backup/restore
this.showSettingsSidebar();
// Set localStorage to maintain Settings sidebar preference
localStorage.setItem('huntarr-settings-sidebar', 'true');
// Initialize backup/restore functionality if not already done
this.initializeBackupRestore();
} else if (section === 'prowlarr' && document.getElementById('prowlarrSection')) {
document.getElementById('prowlarrSection').classList.add('active');
document.getElementById('prowlarrSection').style.display = 'block';
@@ -5005,6 +5020,17 @@ let huntarrUI = {
});
},
initializeBackupRestore: function() {
console.log('[huntarrUI] initializeBackupRestore called');
// Initialize backup/restore functionality
if (typeof BackupRestore !== 'undefined') {
BackupRestore.initialize();
} else {
console.error('[huntarrUI] BackupRestore module not loaded');
}
},
initializeProwlarr: function() {
console.log('[huntarrUI] initializeProwlarr called');

View File

@@ -0,0 +1,390 @@
<section id="backupRestoreSection" class="content-section" style="display: none;">
<div class="section-header">
<h2><i class="fas fa-database"></i> Backup / Restore</h2>
<p>Manage database backups and restore points for your Huntarr installation.</p>
</div>
<div class="backup-restore-container">
<!-- Backup Settings -->
<div class="settings-group">
<h3><i class="fas fa-clock"></i> Automatic Backup Settings</h3>
<div class="form-group">
<label for="backup-frequency">
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#backup-frequency"
class="info-icon" target="_blank" rel="noopener"></a>
Backup Frequency (Days)
</label>
<input type="number" id="backup-frequency" min="1" max="30" value="3"
placeholder="Enter number of days between backups">
<small>How often Huntarr should automatically create database backups (default: 3 days)</small>
</div>
<div class="form-group">
<label for="backup-retention">
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#backup-retention"
class="info-icon" target="_blank" rel="noopener"></a>
Backup Retention Count
</label>
<input type="number" id="backup-retention" min="1" max="10" value="3"
placeholder="Number of backups to keep">
<small>Number of backup copies to keep. Older backups are automatically deleted (default: 3)</small>
</div>
<div class="form-group">
<label>Next Scheduled Backup</label>
<div id="next-backup-time" class="info-display">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
</div>
<!-- Manual Backup -->
<div class="settings-group">
<h3><i class="fas fa-save"></i> Manual Backup</h3>
<div class="form-group">
<label>Create Backup Now</label>
<button type="button" id="create-backup-btn" class="action-btn backup-btn">
<i class="fas fa-plus-circle"></i> Create Manual Backup
</button>
<small>Create an immediate backup of all databases</small>
</div>
<div id="backup-progress" class="progress-container" style="display: none;">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="progress-text">Creating backup...</div>
</div>
</div>
<!-- Restore Database -->
<div class="settings-group">
<h3><i class="fas fa-undo"></i> Restore Database</h3>
<div class="form-group">
<label for="restore-backup-select">
<a href="https://plexguide.github.io/Huntarr.io/settings/backup-restore.html#restore-database"
class="info-icon" target="_blank" rel="noopener"></a>
Select Backup to Restore
</label>
<select id="restore-backup-select">
<option value="">Loading available backups...</option>
</select>
<small>Select a backup to restore. This will overwrite your current database!</small>
</div>
<div class="form-group" id="restore-confirmation-group" style="display: none;">
<label for="restore-confirmation">
Type "RESTORE" to confirm (this will overwrite current data)
</label>
<input type="text" id="restore-confirmation" placeholder="Type RESTORE to confirm"
style="border: 2px solid #e74c3c;">
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This action will permanently overwrite your current database with the
selected backup.
All current data will be lost. Make sure you have a recent backup before proceeding.
</div>
</div>
<div class="form-group" id="restore-action-group" style="display: none;">
<button type="button" id="restore-backup-btn" class="action-btn danger-btn" disabled>
<i class="fas fa-exclamation-triangle"></i> Restore Database
</button>
</div>
</div>
<!-- Destructive Actions -->
<div class="settings-group danger-zone">
<h3><i class="fas fa-trash-alt"></i> Danger Zone</h3>
<div class="form-group">
<label>Delete Current Database</label>
<p class="danger-description">
<i class="fas fa-exclamation-triangle"></i>
This will permanently delete your current database. Use this for testing or complete reset purposes
only.
</p>
<div id="delete-confirmation-group">
<label for="delete-confirmation">
Type "huntarr" to enable deletion
</label>
<input type="text" id="delete-confirmation" placeholder="Type huntarr to confirm"
style="border: 2px solid #e74c3c;">
</div>
<div id="delete-action-group" style="display: none;">
<button type="button" id="delete-database-btn" class="action-btn danger-btn" disabled>
<i class="fas fa-trash-alt"></i> Delete Database
</button>
<div class="warning-box">
<i class="fas fa-skull-crossbones"></i>
<strong>FINAL WARNING:</strong> This action cannot be undone. Your entire Huntarr database will
be permanently deleted.
</div>
</div>
</div>
</div>
<!-- Backup List -->
<div class="settings-group">
<h3><i class="fas fa-list"></i> Available Backups</h3>
<div id="backup-list-container">
<div class="backup-list-loading">
<i class="fas fa-spinner fa-spin"></i> Loading backup list...
</div>
</div>
</div>
</div>
</section>
<style>
.backup-restore-container {
max-width: 800px;
margin: 0 auto;
}
.settings-group {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.settings-group h3 {
color: var(--text-primary);
margin-bottom: 15px;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--text-secondary);
font-size: 12px;
}
.action-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.backup-btn {
background: #10b981;
color: white;
}
.backup-btn:hover {
background: #059669;
}
.danger-btn {
background: #e74c3c;
color: white;
}
.danger-btn:hover:not(:disabled) {
background: #c0392b;
}
.danger-btn:disabled {
background: #6b7280;
color: #9ca3af;
cursor: not-allowed;
}
.danger-zone {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.05);
}
.danger-zone h3 {
color: #e74c3c;
}
.danger-description {
color: #e74c3c;
font-weight: 500;
margin-bottom: 15px;
}
.warning-box {
background: rgba(231, 76, 60, 0.1);
border: 1px solid #e74c3c;
border-radius: 4px;
padding: 12px;
margin-top: 10px;
color: #e74c3c;
font-size: 14px;
}
.info-display {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
color: var(--text-secondary);
}
.progress-container {
margin-top: 15px;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: #10b981;
width: 0%;
transition: width 0.3s ease;
animation: progress-pulse 1.5s infinite;
}
@keyframes progress-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
.backup-list-loading {
text-align: center;
padding: 20px;
color: var(--text-secondary);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 8px;
background: var(--bg-primary);
}
.backup-info {
flex: 1;
}
.backup-name {
font-weight: 500;
color: var(--text-primary);
}
.backup-details {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.backup-actions {
display: flex;
gap: 8px;
}
.backup-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-backup-btn {
background: #e74c3c;
color: white;
}
.delete-backup-btn:hover {
background: #c0392b;
}
.info-icon {
color: #3b82f6;
text-decoration: none;
margin-right: 5px;
font-weight: bold;
}
.info-icon:hover {
color: #1d4ed8;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.backup-restore-container {
padding: 0 10px;
}
.backup-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.backup-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
</section>

View File

@@ -293,6 +293,13 @@
<span>Notifications</span>
</a>
<a href="./#backup-restore" class="nav-item" id="settingsBackupRestoreNav">
<div class="nav-icon-wrapper">
<i class="fas fa-database"></i>
</div>
<span>Backup / Restore</span>
</a>
<a href="./user" class="nav-item" id="settingsUserNav">
<div class="nav-icon-wrapper">
<i class="fas fa-user"></i>

View File

@@ -66,6 +66,9 @@
<!-- Notifications Section -->
{% include 'components/notifications_section.html' %}
<!-- Backup / Restore Section -->
{% include 'components/backup_restore_section.html' %}
<!-- Prowlarr Section -->
{% include 'components/prowlarr_section.html' %}
@@ -83,6 +86,8 @@
<script src="./static/js/apps.js"></script>
<!-- Load logging module -->
<script src="./static/js/logs.js"></script>
<!-- Load backup/restore module -->
<script src="./static/js/backup-restore.js"></script>
<!-- Load main UI script -->
<script src="./static/js/new-main.js"></script>
<!-- Load API progress bar enhancement -->

335
rules.md
View File

@@ -31,14 +31,15 @@ docker logs huntarr
#### **Sidebar Types:**
1. **Main Sidebar** (`#sidebar`) - Default navigation (Home, Apps, Swaparr, Requestarr, etc.)
2. **Apps Sidebar** (`#apps-sidebar`) - App-specific navigation (Sonarr, Radarr, Lidarr, etc.)
3. **Settings Sidebar** (`#settings-sidebar`) - Settings navigation (Main, Scheduling, Notifications, User)
3. **Settings Sidebar** (`#settings-sidebar`) - Settings navigation (Main, Scheduling, Notifications, Backup/Restore, User)
4. **Requestarr Sidebar** (`#requestarr-sidebar`) - Requestarr navigation (Home, History)
#### **Navigation Logic Pattern:**
```javascript
// CRITICAL: All sidebar sections must be included in initialization logic
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' ||
this.currentSection === 'notifications' || this.currentSection === 'user') {
this.currentSection === 'notifications' || this.currentSection === 'backup-restore' ||
this.currentSection === 'user') {
this.showSettingsSidebar();
} else if (this.currentSection === 'requestarr' || this.currentSection === 'requestarr-history') {
this.showRequestarrSidebar();
@@ -52,6 +53,12 @@ if (this.currentSection === 'settings' || this.currentSection === 'scheduling' |
}
```
#### **Apps Navigation Behavior:**
- **Clicking "Apps"** → Automatically redirects to Sonarr (most commonly used app)
- **Apps nav item stays highlighted** when viewing any app (Sonarr, Radarr, etc.)
- **Apps sidebar provides navigation** between individual apps
- **Direct URLs still work** (`#sonarr`, `#radarr`, etc.) for bookmarking specific apps
#### **Sidebar Switching Functions:**
```javascript
showMainSidebar: function() {
@@ -415,6 +422,263 @@ docker exec huntarr du -h /config/huntarr.db
5. **Unique constraints prevent duplicates** - app_name combinations are unique
6. **Transactions for consistency** - DatabaseManager handles commit/rollback
## 💾 BACKUP & RESTORE SYSTEM
### Overview
**Huntarr includes a comprehensive backup and restore system for database protection and recovery.**
**Key Features:**
- **Automatic scheduled backups** based on user-defined frequency
- **Manual backup creation** with progress tracking
- **Multi-database backup** (huntarr.db, logs.db, manager.db)
- **Database integrity verification** before and after operations
- **Cross-platform backup storage** with environment detection
- **Backup retention management** with automatic cleanup
- **Safe restoration** with pre-restore backup creation
### Backup Storage Structure
```
/config/backups/ # Docker environment
├── scheduled_backup_2025-08-24_02-05-16/ # Backup folder (timestamp-based)
│ ├── backup_info.json # Backup metadata
│ ├── huntarr.db # Main database backup
│ ├── logs.db # Logs database backup
│ └── manager.db # Manager database backup (if exists)
└── manual_backup_2025-08-24_14-30-00/ # Manual backup folder
├── backup_info.json
└── [database files...]
```
**Storage Locations:**
- **Docker:** `/config/backups/` (persistent volume)
- **Windows:** `%APPDATA%/Huntarr/backups/`
- **Local Development:** `{project_root}/data/backups/`
### Backup System Architecture
#### **BackupManager Class** (`src/routes/backup_routes.py`)
```python
class BackupManager:
def create_backup(self, backup_type='manual', name=None) # Creates verified backup
def restore_backup(self, backup_id) # Restores with pre-backup
def list_backups(self) # Lists available backups
def delete_backup(self, backup_id) # Deletes specific backup
def get_backup_settings(self) # Gets user settings
def save_backup_settings(self, settings) # Saves user settings
def _cleanup_old_backups(self) # Retention management
```
#### **BackupScheduler Class** (`src/routes/backup_routes.py`)
```python
class BackupScheduler:
def start(self) # Starts background scheduler thread
def stop(self) # Gracefully stops scheduler
def _should_create_backup(self) # Checks if backup is due
def _scheduler_loop(self) # Main scheduling loop (hourly checks)
```
### API Endpoints
```python
# Backup Settings
GET/POST /api/backup/settings # Get/set backup frequency & retention
# Backup Operations
POST /api/backup/create # Create manual backup
GET /api/backup/list # List available backups
POST /api/backup/restore # Restore from backup
POST /api/backup/delete # Delete specific backup
GET /api/backup/next-scheduled # Get next scheduled backup time
# Destructive Operations
POST /api/backup/delete-database # Delete current database (testing/reset)
```
### Frontend Integration
#### **Settings Navigation** (`frontend/templates/components/sidebar.html`)
```html
<!-- Added to Settings Sidebar -->
<a href="./#backup-restore" class="nav-item" id="settingsBackupRestoreNav">
<div class="nav-icon-wrapper">
<i class="fas fa-database"></i>
</div>
<span>Backup / Restore</span>
</a>
```
#### **Section Handling** (`frontend/static/js/new-main.js`)
```javascript
// CRITICAL: backup-restore must be included in settings sections
if (this.currentSection === 'settings' || this.currentSection === 'scheduling' ||
this.currentSection === 'notifications' || this.currentSection === 'backup-restore' ||
this.currentSection === 'user') {
this.showSettingsSidebar();
}
// Section initialization
} else if (section === 'backup-restore' && document.getElementById('backupRestoreSection')) {
document.getElementById('backupRestoreSection').classList.add('active');
document.getElementById('backupRestoreSection').style.display = 'block';
if (document.getElementById('settingsBackupRestoreNav'))
document.getElementById('settingsBackupRestoreNav').classList.add('active');
this.currentSection = 'backup-restore';
this.showSettingsSidebar();
this.initializeBackupRestore();
}
```
#### **JavaScript Module** (`frontend/static/js/backup-restore.js`)
```javascript
const BackupRestore = {
initialize: function() // Main initialization
createManualBackup: function() // Manual backup with progress
restoreBackup: function() // Restore with confirmations
deleteDatabase: function() # Destructive operation with safeguards
loadBackupList: function() // Refresh backup list
validateRestoreConfirmation: function() // "RESTORE" confirmation
validateDeleteConfirmation: function() // "huntarr" confirmation
}
```
### Safety & Security Features
#### **Multi-Level Confirmations**
1. **Restore Operations:**
- Select backup from dropdown
- Type "RESTORE" to enable button
- Final browser confirmation dialog
- Pre-restore backup creation
2. **Database Deletion:**
- Type "huntarr" to enable deletion
- Final browser confirmation dialog
- Multiple warning messages
#### **Data Integrity**
```python
def _verify_database_integrity(self, db_path):
"""Verify database integrity using SQLite PRAGMA"""
conn = sqlite3.connect(db_path)
result = conn.execute("PRAGMA integrity_check").fetchone()
return result and result[0] == "ok"
```
#### **Backup Verification Process**
1. **Pre-backup:** Force WAL checkpoint
2. **During backup:** Copy all database files
3. **Post-backup:** Verify each backup file integrity
4. **Cleanup:** Remove corrupted backups automatically
### Configuration & Settings
#### **Default Settings**
```python
backup_settings = {
'frequency': 3, # Days between automatic backups
'retention': 3 # Number of backups to keep
}
```
#### **Settings Storage** (Database)
```sql
-- Stored in general_settings table
INSERT INTO general_settings (setting_key, setting_value) VALUES
('backup_frequency', '3'),
('backup_retention', '3'),
('last_backup_time', '2025-08-24T02:05:16');
```
### Troubleshooting Backup Issues
#### **Common Problems & Solutions**
1. **Backup Directory Not Writable**
```python
# System falls back to alternative locations
# Windows: Documents/Huntarr/ if AppData fails
# Check logs for "Database directory not writable"
```
2. **Database Corruption During Backup**
```bash
# Check integrity manually
docker exec huntarr sqlite3 /config/huntarr.db "PRAGMA integrity_check"
# Backup system auto-detects and handles corruption
# Creates corrupted_backup_[timestamp].db for recovery
```
3. **Scheduler Not Running**
```bash
# Check logs for scheduler startup
docker logs huntarr | grep -i "backup scheduler"
# Should see: "Backup scheduler started"
```
4. **Restore Failures**
```python
# System creates pre-restore backup automatically
# Check /config/backups/ for pre_restore_backup_[timestamp] folders
# Original data preserved even if restore fails
```
#### **Debugging Commands**
```bash
# Check backup directory
docker exec huntarr ls -la /config/backups/
# Verify backup integrity
docker exec huntarr sqlite3 /config/backups/[backup_folder]/huntarr.db "PRAGMA integrity_check"
# Check backup settings
docker exec huntarr sqlite3 /config/huntarr.db "SELECT * FROM general_settings WHERE setting_key LIKE 'backup_%'"
# Monitor backup scheduler
docker logs huntarr | grep -i backup
```
### Development Guidelines for Backup System
#### **Adding New Database Files**
```python
def _get_all_database_paths(self):
"""Update this method when adding new database files"""
databases = {
'huntarr': str(main_db_path),
'logs': str(logs_db_path),
'manager': str(manager_db_path),
'new_db': str(new_db_path) # Add new databases here
}
return databases
```
#### **Backup Metadata Format**
```json
{
"id": "backup_name",
"name": "backup_name",
"type": "manual|scheduled|pre-restore",
"timestamp": "2025-08-24T02:05:16",
"databases": [
{
"name": "huntarr",
"size": 249856,
"path": "/config/backups/backup_name/huntarr.db"
}
],
"size": 1331200
}
```
#### **Critical Requirements**
1. **Always verify backup integrity** before considering backup complete
2. **Create pre-restore backup** before any restore operation
3. **Use cross-platform paths** - never hardcode `/config/`
4. **Handle backup corruption gracefully** with user notifications
5. **Implement proper cleanup** based on retention settings
6. **Provide clear user feedback** for all operations
## 🔧 DEVELOPMENT WORKFLOW
### Before Any Changes
@@ -438,6 +702,11 @@ docker exec huntarr du -h /config/huntarr.db
- [ ] Test subpath scenarios (`domain.com/huntarr/`)
- [ ] Check browser console for errors
- [ ] **NEW:** Verify database persistence across container restarts
- [ ] **NEW:** Test backup/restore functionality if modified:
- [ ] Verify backup creation and integrity
- [ ] Test backup scheduler startup
- [ ] Verify backup directory creation (`/config/backups/`)
- [ ] Test restore confirmation workflow
- [ ] Get user approval before committing
### Proactive Violation Scanning
@@ -489,15 +758,20 @@ grep -r "fetch('/api/" frontend/ --include="*.js"
- `/src/primary/cycle_tracker.py` - Timer functionality
- `/src/primary/utils/logger.py` - Logging configuration
- `/src/primary/utils/database.py` - **NEW:** DatabaseManager class (replaces settings_manager.py)
- `/src/routes/backup_routes.py` - **NEW:** Backup & Restore API endpoints and BackupManager
### Frontend Core
- `/frontend/static/js/new-main.js` - Main UI logic
- `/frontend/static/js/settings_forms.js` - Settings forms
- `/frontend/static/js/backup-restore.js` - **NEW:** Backup & Restore functionality
- `/frontend/templates/components/` - UI components
- `/frontend/templates/components/backup_restore_section.html` - **NEW:** Backup & Restore UI
### Database & Storage
- `/config/huntarr.db` - **Docker:** Main database file (persistent)
- `./data/huntarr.db` - **Local:** Main database file (development)
- `/config/backups/` - **Docker:** Backup storage directory (persistent)
- `./data/backups/` - **Local:** Backup storage directory (development)
- `/src/primary/utils/database.py` - DatabaseManager with auto-detection
- **REMOVED:** All JSON files (settings.json, stateful.json, etc.)
@@ -702,6 +976,63 @@ grep -r 'id="[^"]*"' docs/apps/ | grep -o 'id="[^"]*"' | sort | uniq
- Updated `src/primary/apps/radarr/upgrade.py` to check release dates
- Both missing and upgrade searches now respect `skip_future_releases` and `process_no_release_dates`
- Documentation updated to clarify behavior affects both search types
### Apps Navigation Redesign (2024-12)
**Issue:** Apps section showed a dashboard that users had to navigate through to reach individual app settings
**User Feedback:** "Instead of an apps dashboard; make it where when a user clicks apps, it goes to the sonarr apps being selected by default"
**Solution:** Direct navigation to Sonarr when clicking Apps, eliminating the dashboard step
**Implementation Changes:**
1. **Modified Apps Section Navigation** (`frontend/static/js/new-main.js`):
```javascript
// OLD: Showed apps dashboard
} else if (section === 'apps') {
document.getElementById('appsSection').classList.add('active');
// ... dashboard logic
// NEW: Direct redirect to Sonarr
} else if (section === 'apps') {
console.log('[huntarrUI] Apps section requested - redirecting to Sonarr by default');
this.switchSection('sonarr');
window.location.hash = '#sonarr';
return;
```
2. **Updated Navigation Highlighting** (`frontend/templates/components/sidebar.html`):
```javascript
// Keep "Apps" nav item active when viewing Sonarr
} else if (currentHash === '#apps' || currentHash === '#sonarr') {
selector = '#appsNav';
```
3. **Preserved Apps Sidebar Functionality**:
- Apps sidebar still provides navigation between all apps (Sonarr, Radarr, Lidarr, etc.)
- Return button allows going back to main navigation
- Individual app sections remain fully functional
**User Experience Improvements:**
- **Faster Access**: Click "Apps" → Immediately see Sonarr settings (most commonly used)
- **Intuitive Navigation**: Apps nav item stays highlighted when viewing any app
- **Preserved Functionality**: All apps still accessible via Apps sidebar
- **Consistent Behavior**: Maintains existing sidebar switching patterns
**Navigation Flow:**
```
Main Sidebar: Apps → Sonarr (default)
Apps Sidebar: Sonarr ↔ Radarr ↔ Lidarr ↔ Readarr ↔ Whisparr V2 ↔ Whisparr V3 ↔ Prowlarr
```
**Files Modified:**
- `frontend/static/js/new-main.js` - Apps section redirect logic
- `frontend/templates/components/sidebar.html` - Navigation highlighting logic
- Removed dashboard functionality from `frontend/templates/components/apps_section.html`
**Benefits:**
- Eliminates unnecessary dashboard step
- Provides direct access to most commonly used app (Sonarr)
- Maintains all existing functionality through sidebar navigation
- Improves user workflow efficiency
- Frontend info icons fixed to use GitHub documentation links
**User Benefit:** Consistent behavior - no more unexpected future movie upgrades

View File

@@ -50,6 +50,9 @@ from src.primary.routes.history_routes import history_blueprint
# Import scheduler blueprint
from src.primary.routes.scheduler_routes import scheduler_api
# Import backup blueprint
from src.routes.backup_routes import backup_bp
# Import log routes blueprint
from src.primary.routes.log_routes import log_routes_bp
@@ -277,6 +280,7 @@ app.register_blueprint(stateful_api, url_prefix='/api/stateful')
app.register_blueprint(history_blueprint, url_prefix='/api/hunt-manager')
app.register_blueprint(scheduler_api)
app.register_blueprint(log_routes_bp)
app.register_blueprint(backup_bp)
# Register the authentication check to run before requests
app.before_request(authenticate_request)

629
src/routes/backup_routes.py Normal file
View File

@@ -0,0 +1,629 @@
"""
Backup and Restore API routes for Huntarr
Handles database backup creation, restoration, and management
"""
import os
import json
import shutil
import sqlite3
import time
import threading
from datetime import datetime, timedelta
from pathlib import Path
from flask import Blueprint, request, jsonify
from src.primary.utils.database import get_database
from src.primary.routes.common import get_user_for_request
import logging
logger = logging.getLogger(__name__)
backup_bp = Blueprint('backup', __name__)
class BackupScheduler:
"""Handles automatic backup scheduling"""
def __init__(self, backup_manager):
self.backup_manager = backup_manager
self.scheduler_thread = None
self.stop_event = threading.Event()
self.running = False
def start(self):
"""Start the backup scheduler"""
if self.running:
return
self.stop_event.clear()
self.scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
self.scheduler_thread.start()
self.running = True
logger.info("Backup scheduler started")
def stop(self):
"""Stop the backup scheduler"""
if not self.running:
return
self.stop_event.set()
if self.scheduler_thread:
self.scheduler_thread.join(timeout=5)
self.running = False
logger.info("Backup scheduler stopped")
def _scheduler_loop(self):
"""Main scheduler loop"""
while not self.stop_event.is_set():
try:
if self._should_create_backup():
logger.info("Creating scheduled backup")
backup_info = self.backup_manager.create_backup('scheduled', None)
# Update last backup time
self.backup_manager.db.set_general_setting('last_backup_time', backup_info['timestamp'])
logger.info(f"Scheduled backup created: {backup_info['name']}")
# Check every hour
self.stop_event.wait(3600)
except Exception as e:
logger.error(f"Error in backup scheduler: {e}")
# Wait before retrying
self.stop_event.wait(300) # 5 minutes
def _should_create_backup(self):
"""Check if a backup should be created"""
try:
settings = self.backup_manager.get_backup_settings()
frequency_days = settings['frequency']
last_backup_time = self.backup_manager.db.get_general_setting('last_backup_time')
if not last_backup_time:
# No previous backup, create one
return True
last_backup = datetime.fromisoformat(last_backup_time)
next_backup = last_backup + timedelta(days=frequency_days)
return datetime.now() >= next_backup
except Exception as e:
logger.error(f"Error checking backup schedule: {e}")
return False
# Global backup scheduler instance
backup_scheduler = None
class BackupManager:
"""Manages database backups and restoration"""
def __init__(self):
self.db = get_database()
self.backup_dir = self._get_backup_directory()
self.ensure_backup_directory()
def _get_backup_directory(self):
"""Get the backup directory path based on environment"""
# Check if running in Docker (config directory exists)
config_dir = Path("/config")
if config_dir.exists() and config_dir.is_dir():
return config_dir / "backups"
# Check Windows AppData
import platform
if platform.system() == "Windows":
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
windows_config_dir = Path(appdata) / "Huntarr"
return windows_config_dir / "backups"
# For local development, use data directory in project root
project_root = Path(__file__).parent.parent.parent
data_dir = project_root / "data"
return data_dir / "backups"
def ensure_backup_directory(self):
"""Ensure backup directory exists"""
try:
self.backup_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Backup directory ensured: {self.backup_dir}")
except Exception as e:
logger.error(f"Failed to create backup directory: {e}")
raise
def get_backup_settings(self):
"""Get backup settings from database"""
try:
frequency = self.db.get_general_setting('backup_frequency', 3)
retention = self.db.get_general_setting('backup_retention', 3)
return {
'frequency': int(frequency),
'retention': int(retention)
}
except Exception as e:
logger.error(f"Error getting backup settings: {e}")
return {'frequency': 3, 'retention': 3}
def save_backup_settings(self, settings):
"""Save backup settings to database"""
try:
self.db.set_general_setting('backup_frequency', settings.get('frequency', 3))
self.db.set_general_setting('backup_retention', settings.get('retention', 3))
logger.info(f"Backup settings saved: {settings}")
return True
except Exception as e:
logger.error(f"Error saving backup settings: {e}")
return False
def create_backup(self, backup_type='manual', name=None):
"""Create a backup of all databases"""
try:
# Generate backup name if not provided
if not name:
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
name = f"{backup_type}_backup_{timestamp}"
# Create backup folder with timestamp
backup_folder = self.backup_dir / name
backup_folder.mkdir(parents=True, exist_ok=True)
# Get all database paths
databases = self._get_all_database_paths()
backup_info = {
'id': name,
'name': name,
'type': backup_type,
'timestamp': datetime.now().isoformat(),
'databases': [],
'size': 0
}
# Backup each database
for db_name, db_path in databases.items():
if Path(db_path).exists():
backup_db_path = backup_folder / f"{db_name}.db"
# Force WAL checkpoint before backup
try:
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.close()
except Exception as e:
logger.warning(f"Could not checkpoint {db_name}: {e}")
# Copy database file
shutil.copy2(db_path, backup_db_path)
# Verify backup integrity
if self._verify_database_integrity(backup_db_path):
db_size = backup_db_path.stat().st_size
backup_info['databases'].append({
'name': db_name,
'size': db_size,
'path': str(backup_db_path)
})
backup_info['size'] += db_size
logger.info(f"Backed up {db_name} ({db_size} bytes)")
else:
logger.error(f"Backup verification failed for {db_name}")
backup_db_path.unlink(missing_ok=True)
raise Exception(f"Backup verification failed for {db_name}")
# Save backup metadata
metadata_path = backup_folder / "backup_info.json"
with open(metadata_path, 'w') as f:
json.dump(backup_info, f, indent=2)
# Clean up old backups based on retention policy
self._cleanup_old_backups()
logger.info(f"Backup created successfully: {name} ({backup_info['size']} bytes)")
return backup_info
except Exception as e:
logger.error(f"Error creating backup: {e}")
# Clean up failed backup
if 'backup_folder' in locals() and backup_folder.exists():
shutil.rmtree(backup_folder, ignore_errors=True)
raise
def _get_all_database_paths(self):
"""Get paths to all Huntarr databases"""
databases = {}
# Main database
main_db_path = self.db.db_path
databases['huntarr'] = str(main_db_path)
# Logs database (if exists)
logs_db_path = main_db_path.parent / "logs.db"
if logs_db_path.exists():
databases['logs'] = str(logs_db_path)
# Manager database (if exists)
manager_db_path = main_db_path.parent / "manager.db"
if manager_db_path.exists():
databases['manager'] = str(manager_db_path)
return databases
def _verify_database_integrity(self, db_path):
"""Verify database integrity"""
try:
conn = sqlite3.connect(db_path)
result = conn.execute("PRAGMA integrity_check").fetchone()
conn.close()
return result and result[0] == "ok"
except Exception as e:
logger.error(f"Database integrity check failed: {e}")
return False
def list_backups(self):
"""List all available backups"""
try:
backups = []
if not self.backup_dir.exists():
return backups
for backup_folder in self.backup_dir.iterdir():
if backup_folder.is_dir():
metadata_path = backup_folder / "backup_info.json"
if metadata_path.exists():
try:
with open(metadata_path, 'r') as f:
backup_info = json.load(f)
backups.append(backup_info)
except Exception as e:
logger.warning(f"Could not read backup metadata for {backup_folder.name}: {e}")
# Create basic info from folder
backups.append({
'id': backup_folder.name,
'name': backup_folder.name,
'type': 'unknown',
'timestamp': datetime.fromtimestamp(backup_folder.stat().st_mtime).isoformat(),
'size': sum(f.stat().st_size for f in backup_folder.rglob('*.db') if f.is_file())
})
# Sort by timestamp (newest first)
backups.sort(key=lambda x: x['timestamp'], reverse=True)
return backups
except Exception as e:
logger.error(f"Error listing backups: {e}")
return []
def restore_backup(self, backup_id):
"""Restore a backup"""
try:
backup_folder = self.backup_dir / backup_id
if not backup_folder.exists():
raise Exception(f"Backup not found: {backup_id}")
# Load backup metadata
metadata_path = backup_folder / "backup_info.json"
if metadata_path.exists():
with open(metadata_path, 'r') as f:
backup_info = json.load(f)
else:
raise Exception("Backup metadata not found")
# Get current database paths
databases = self._get_all_database_paths()
# Create backup of current databases before restore
current_backup_name = f"pre_restore_backup_{int(time.time())}"
logger.info(f"Creating backup of current databases: {current_backup_name}")
self.create_backup('pre-restore', current_backup_name)
# Restore each database
restored_databases = []
for db_info in backup_info.get('databases', []):
db_name = db_info['name']
backup_db_path = Path(db_info['path'])
if db_name in databases and backup_db_path.exists():
current_db_path = Path(databases[db_name])
# Verify backup database integrity
if not self._verify_database_integrity(backup_db_path):
raise Exception(f"Backup database {db_name} is corrupted")
# Stop any connections to the database
if hasattr(self.db, 'close_connections'):
self.db.close_connections()
# Replace current database with backup
if current_db_path.exists():
current_db_path.unlink()
shutil.copy2(backup_db_path, current_db_path)
# Verify restored database
if self._verify_database_integrity(current_db_path):
restored_databases.append(db_name)
logger.info(f"Restored database: {db_name}")
else:
raise Exception(f"Restored database {db_name} failed integrity check")
logger.info(f"Backup restored successfully: {backup_id}")
return {
'backup_id': backup_id,
'restored_databases': restored_databases,
'timestamp': datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error restoring backup: {e}")
raise
def delete_backup(self, backup_id):
"""Delete a backup"""
try:
backup_folder = self.backup_dir / backup_id
if not backup_folder.exists():
raise Exception(f"Backup not found: {backup_id}")
shutil.rmtree(backup_folder)
logger.info(f"Backup deleted: {backup_id}")
return True
except Exception as e:
logger.error(f"Error deleting backup: {e}")
raise
def delete_database(self):
"""Delete the current database (destructive operation)"""
try:
databases = self._get_all_database_paths()
deleted_databases = []
for db_name, db_path in databases.items():
db_file = Path(db_path)
if db_file.exists():
db_file.unlink()
deleted_databases.append(db_name)
logger.warning(f"Deleted database: {db_name}")
logger.warning(f"Database deletion completed: {deleted_databases}")
return deleted_databases
except Exception as e:
logger.error(f"Error deleting database: {e}")
raise
def _cleanup_old_backups(self):
"""Clean up old backups based on retention policy"""
try:
settings = self.get_backup_settings()
retention_count = settings['retention']
backups = self.list_backups()
# Keep only the most recent backups
if len(backups) > retention_count:
backups_to_delete = backups[retention_count:]
for backup in backups_to_delete:
try:
self.delete_backup(backup['id'])
logger.info(f"Cleaned up old backup: {backup['id']}")
except Exception as e:
logger.warning(f"Failed to clean up backup {backup['id']}: {e}")
except Exception as e:
logger.error(f"Error during backup cleanup: {e}")
def get_next_scheduled_backup(self):
"""Get the next scheduled backup time"""
try:
settings = self.get_backup_settings()
frequency_days = settings['frequency']
# Get the last backup time
last_backup_time = self.db.get_general_setting('last_backup_time')
if last_backup_time:
last_backup = datetime.fromisoformat(last_backup_time)
next_backup = last_backup + timedelta(days=frequency_days)
else:
# If no previous backup, schedule for tomorrow
next_backup = datetime.now() + timedelta(days=1)
return next_backup.isoformat()
except Exception as e:
logger.error(f"Error calculating next backup time: {e}")
return None
# Initialize backup manager and scheduler
backup_manager = BackupManager()
backup_scheduler = BackupScheduler(backup_manager)
# Start the backup scheduler
backup_scheduler.start()
@backup_bp.route('/api/backup/settings', methods=['GET', 'POST'])
def backup_settings():
"""Get or set backup settings"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
if request.method == 'GET':
settings = backup_manager.get_backup_settings()
return jsonify({
'success': True,
'settings': settings
})
elif request.method == 'POST':
data = request.get_json() or {}
# Validate settings
frequency = int(data.get('frequency', 3))
retention = int(data.get('retention', 3))
if frequency < 1 or frequency > 30:
return jsonify({"success": False, "error": "Frequency must be between 1 and 30 days"}), 400
if retention < 1 or retention > 10:
return jsonify({"success": False, "error": "Retention must be between 1 and 10 backups"}), 400
settings = {
'frequency': frequency,
'retention': retention
}
if backup_manager.save_backup_settings(settings):
return jsonify({
'success': True,
'settings': settings
})
else:
return jsonify({"success": False, "error": "Failed to save settings"}), 500
except Exception as e:
logger.error(f"Error in backup settings: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/create', methods=['POST'])
def create_backup():
"""Create a manual backup"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
data = request.get_json() or {}
backup_type = data.get('type', 'manual')
backup_name = data.get('name')
backup_info = backup_manager.create_backup(backup_type, backup_name)
# Update last backup time
backup_manager.db.set_general_setting('last_backup_time', backup_info['timestamp'])
return jsonify({
'success': True,
'backup_name': backup_info['name'],
'backup_size': backup_info['size'],
'timestamp': backup_info['timestamp']
})
except Exception as e:
logger.error(f"Error creating backup: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/list', methods=['GET'])
def list_backups():
"""List all available backups"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
backups = backup_manager.list_backups()
return jsonify({
'success': True,
'backups': backups
})
except Exception as e:
logger.error(f"Error listing backups: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/restore', methods=['POST'])
def restore_backup():
"""Restore a backup"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
data = request.get_json() or {}
backup_id = data.get('backup_id')
if not backup_id:
return jsonify({"success": False, "error": "Backup ID required"}), 400
restore_info = backup_manager.restore_backup(backup_id)
return jsonify({
'success': True,
'restore_info': restore_info
})
except Exception as e:
logger.error(f"Error restoring backup: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/delete', methods=['POST'])
def delete_backup():
"""Delete a backup"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
data = request.get_json() or {}
backup_id = data.get('backup_id')
if not backup_id:
return jsonify({"success": False, "error": "Backup ID required"}), 400
backup_manager.delete_backup(backup_id)
return jsonify({
'success': True,
'message': f'Backup {backup_id} deleted successfully'
})
except Exception as e:
logger.error(f"Error deleting backup: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/delete-database', methods=['POST'])
def delete_database():
"""Delete the current database (destructive operation)"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
deleted_databases = backup_manager.delete_database()
return jsonify({
'success': True,
'deleted_databases': deleted_databases,
'message': 'Database deleted successfully'
})
except Exception as e:
logger.error(f"Error deleting database: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@backup_bp.route('/api/backup/next-scheduled', methods=['GET'])
def next_scheduled_backup():
"""Get the next scheduled backup time"""
username = get_user_for_request()
if not username:
return jsonify({"success": False, "error": "Authentication required"}), 401
try:
next_backup = backup_manager.get_next_scheduled_backup()
return jsonify({
'success': True,
'next_backup': next_backup
})
except Exception as e:
logger.error(f"Error getting next backup time: {e}")
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -1 +1 @@
8.2.7
8.2.8