Add Backup Manager dashboard tool card with list, download, restore & delete

This commit is contained in:
Broque Thomas 2026-03-08 10:46:03 -07:00
parent b1cb9f9964
commit 266d044797
4 changed files with 400 additions and 33 deletions

View file

@ -15255,15 +15255,17 @@ def stop_database_update():
else:
return jsonify({"success": False, "error": "No update is currently running."}), 404
_BACKUP_FILENAME_RE = re.compile(r'^music_library\.db\.backup_\d{8}_\d{6}$')
@app.route('/api/database/backup', methods=['POST'])
def backup_database_endpoint():
"""Create a rolling backup of the database (max 3)."""
"""Create a rolling backup of the database (max 5)."""
try:
import sqlite3, glob as _glob
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
if not os.path.exists(db_path):
return jsonify({"success": False, "error": "Database file not found"}), 404
max_backups = 3
max_backups = 5
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{db_path}.backup_{timestamp}"
src = sqlite3.connect(db_path)
@ -15283,6 +15285,116 @@ def backup_database_endpoint():
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/database/backups', methods=['GET'])
def list_backups_endpoint():
"""List all database backups with metadata."""
try:
import glob as _glob
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
backup_files = sorted(
_glob.glob(f"{db_path}.backup_*"),
key=os.path.getmtime,
reverse=True
)
backups = []
for fp in backup_files:
fname = os.path.basename(fp)
if not _BACKUP_FILENAME_RE.match(fname):
continue
stat = os.stat(fp)
backups.append({
'filename': fname,
'size_mb': round(stat.st_size / (1024 * 1024), 2),
'created': datetime.utcfromtimestamp(stat.st_mtime).isoformat()
})
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2) if os.path.exists(db_path) else 0
return jsonify({
'success': True,
'backups': backups,
'count': len(backups),
'db_size_mb': db_size_mb
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/database/backups/<filename>', methods=['DELETE'])
def delete_backup_endpoint(filename):
"""Delete a specific database backup."""
try:
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
backup_path = os.path.join(os.path.dirname(db_path), filename)
if not os.path.exists(backup_path):
return jsonify({"success": False, "error": "Backup not found"}), 404
os.remove(backup_path)
return jsonify({"success": True, "deleted": filename})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/database/backups/<filename>/restore', methods=['POST'])
def restore_backup_endpoint(filename):
"""Restore the database from a specific backup."""
try:
import sqlite3
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
db_dir = os.path.dirname(db_path)
backup_path = os.path.join(db_dir, filename)
if not os.path.exists(backup_path):
return jsonify({"success": False, "error": "Backup not found"}), 404
# Create safety backup of current DB before restoring
safety_ts = datetime.now().strftime('%Y%m%d_%H%M%S')
safety_filename = f"music_library.db.backup_{safety_ts}"
safety_path = os.path.join(db_dir, safety_filename)
src_conn = sqlite3.connect(db_path)
dst_conn = sqlite3.connect(safety_path)
src_conn.backup(dst_conn)
dst_conn.close()
src_conn.close()
# Restore using SQLite backup API (handles concurrent access safely)
from database.music_database import close_database, get_database
close_database()
src_restore = sqlite3.connect(backup_path)
dst_restore = sqlite3.connect(db_path)
src_restore.backup(dst_restore)
dst_restore.close()
src_restore.close()
# Reinitialize database and verify
db = get_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM artists")
artist_count = cursor.fetchone()[0]
return jsonify({
"success": True,
"restored_from": filename,
"safety_backup": safety_filename,
"artist_count": artist_count
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/database/backups/<filename>/download', methods=['GET'])
def download_backup_endpoint(filename):
"""Download a specific database backup file."""
try:
if not _BACKUP_FILENAME_RE.match(filename) or '/' in filename or '\\' in filename or '..' in filename:
return jsonify({"success": False, "error": "Invalid backup filename"}), 400
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
backup_path = os.path.join(os.path.dirname(db_path), filename)
if not os.path.exists(backup_path):
return jsonify({"success": False, "error": "Backup not found"}), 404
return send_file(backup_path, as_attachment=True, download_name=filename)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == QUALITY SCANNER ==
# ===============================

View file

@ -502,12 +502,8 @@
<div class="tool-card" id="db-updater-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Database Updater</h4>
<div style="display:flex;gap:4px;">
<button class="tool-help-button" id="db-backup-button"
title="Create a rolling backup of the database"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
<button class="tool-help-button" data-tool="db-updater"
title="Learn more about this tool">?</button>
</div>
<button class="tool-help-button" data-tool="db-updater"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
<div class="tool-card-stats">
@ -748,6 +744,37 @@
request</p>
</div>
</div>
<div class="tool-card" id="backup-manager-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Backup Manager</h4>
<button class="tool-help-button" data-tool="backup-manager"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Create, download, restore and manage database backups</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Last Backup:</span>
<span class="stat-item-value" id="backup-stat-last">Never</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Backups:</span>
<span class="stat-item-value" id="backup-stat-count">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Latest Size:</span>
<span class="stat-item-value" id="backup-stat-latest-size"></span>
</div>
<div class="stat-item">
<span class="stat-item-label">DB Size:</span>
<span class="stat-item-value" id="backup-stat-db-size"></span>
</div>
</div>
<div class="tool-card-controls">
<button id="backup-now-button">Backup Now</button>
</div>
<div id="backup-list-container" class="backup-list-container"></div>
</div>
</div>
</div>

View file

@ -15249,6 +15249,132 @@ function stopDuplicateCleanerPolling() {
}
}
// ============================================
// == BACKUP MANAGER ==
// ============================================
async function loadBackupList() {
try {
const res = await fetch('/api/database/backups');
const data = await res.json();
if (data.success) {
updateBackupManagerUI(data);
renderBackupList(data.backups);
}
} catch (e) {
console.error('Failed to load backup list:', e);
}
}
function updateBackupManagerUI(data) {
const lastEl = document.getElementById('backup-stat-last');
const countEl = document.getElementById('backup-stat-count');
const latestSizeEl = document.getElementById('backup-stat-latest-size');
const dbSizeEl = document.getElementById('backup-stat-db-size');
if (countEl) countEl.textContent = data.count;
if (dbSizeEl) dbSizeEl.textContent = data.db_size_mb + ' MB';
if (data.backups && data.backups.length > 0) {
const newest = data.backups[0];
if (lastEl) lastEl.textContent = timeAgo(newest.created);
if (latestSizeEl) latestSizeEl.textContent = newest.size_mb + ' MB';
} else {
if (lastEl) lastEl.textContent = 'Never';
if (latestSizeEl) latestSizeEl.textContent = '—';
}
}
function renderBackupList(backups) {
const container = document.getElementById('backup-list-container');
if (!container) return;
if (!backups || backups.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = backups.map(b => {
const date = new Date(b.created + (b.created.includes('Z') ? '' : 'Z'));
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const safeName = escapeForInlineJs(b.filename);
return `<div class="backup-list-item">
<div class="backup-list-info">
<span class="backup-list-date">${escapeHtml(dateStr)}</span>
<span class="backup-list-size">${b.size_mb} MB</span>
</div>
<div class="backup-list-actions">
<button class="backup-dl-btn" onclick="downloadBackup('${safeName}')" title="Download">DL</button>
<button class="backup-restore-btn" onclick="restoreBackup('${safeName}')" title="Restore">Restore</button>
<button class="backup-delete-btn" onclick="deleteBackup('${safeName}')" title="Delete">Del</button>
</div>
</div>`;
}).join('');
}
async function handleBackupNowClick() {
const button = document.getElementById('backup-now-button');
if (!button) return;
const origText = button.textContent;
button.disabled = true;
button.textContent = 'Backing up...';
try {
const res = await fetch('/api/database/backup', { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
await loadBackupList();
} else {
showToast(`Backup failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Backup request failed', 'error');
}
button.disabled = false;
button.textContent = origText;
}
function downloadBackup(filename) {
const a = document.createElement('a');
a.href = `/api/database/backups/${encodeURIComponent(filename)}/download`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function restoreBackup(filename) {
if (!confirm(`Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`)) return;
try {
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`, 'success');
await loadBackupList();
} else {
showToast(`Restore failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Restore request failed', 'error');
}
}
async function deleteBackup(filename) {
if (!confirm(`Delete backup "${filename}"? This cannot be undone.`)) return;
try {
const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Backup deleted: ${data.deleted}`, 'success');
await loadBackupList();
} else {
showToast(`Delete failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Delete request failed', 'error');
}
}
// ============================================
// == TOOL HELP MODAL ==
// ============================================
@ -16278,6 +16404,35 @@ const TOOL_HELP_CONTENT = {
<h4>Combining with notifications</h4>
<p>You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification all run after the action completes.</p>
`
},
'backup-manager': {
title: 'Backup Manager',
content: `
<h4>What does this tool do?</h4>
<p>The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.</p>
<h4>Features</h4>
<ul>
<li><strong>Backup Now:</strong> Create an instant backup of the current database using SQLite's hot-copy API</li>
<li><strong>Download:</strong> Download any backup file to your local machine</li>
<li><strong>Restore:</strong> Roll back the database to a previous backup state</li>
<li><strong>Delete:</strong> Remove old backups you no longer need</li>
</ul>
<h4>Auto-Backups</h4>
<p>SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).</p>
<h4>Restore Safety</h4>
<p>When you restore from a backup, a <strong>safety backup</strong> of your current database is created first. This means you can always undo a restore if something goes wrong.</p>
<h4>Stats Explained</h4>
<ul>
<li><strong>Last Backup:</strong> When the most recent backup was created</li>
<li><strong>Backups:</strong> Total number of backup files available</li>
<li><strong>Latest Size:</strong> Size of the most recent backup</li>
<li><strong>DB Size:</strong> Current size of the live database</li>
</ul>
`
}
};
@ -16931,11 +17086,6 @@ async function loadDashboardData() {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
}
const backupButton = document.getElementById('db-backup-button');
if (backupButton) {
backupButton.addEventListener('click', handleDbBackupButtonClick);
}
// Attach event listeners for the metadata updater tool
const metadataButton = document.getElementById('metadata-update-button');
if (metadataButton) {
@ -16975,6 +17125,11 @@ async function loadDashboardData() {
// Check active media server and show media scan tool only for Plex
await checkAndShowMediaScanForPlex();
// Attach event listener for the backup manager
const backupNowButton = document.getElementById('backup-now-button');
if (backupNowButton) backupNowButton.addEventListener('click', handleBackupNowClick);
loadBackupList();
// Attach event listeners for tool help buttons
initializeToolHelpButtons();
@ -19047,26 +19202,6 @@ async function handleDbUpdateButtonClick() {
}
}
async function handleDbBackupButtonClick() {
const button = document.getElementById('db-backup-button');
const origText = button.textContent;
button.disabled = true;
button.textContent = 'Backing up...';
try {
const res = await fetch('/api/database/backup', { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
} else {
showToast(`Backup failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Backup request failed', 'error');
}
button.disabled = false;
button.textContent = origText;
}
async function handleWishlistButtonClick() {
try {
const playlistId = 'wishlist';

View file

@ -5423,6 +5423,99 @@ body {
transition: width 0.3s ease;
}
/* Backup Manager List */
.backup-list-container {
max-height: 200px;
overflow-y: auto;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 10px;
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.backup-list-container:empty {
display: none;
}
.backup-list-item {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 8px 10px;
transition: background 0.2s ease;
}
.backup-list-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.backup-list-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.backup-list-date {
font-size: 12px;
color: #e0e0e0;
white-space: nowrap;
}
.backup-list-size {
font-size: 11px;
color: #888;
}
.backup-list-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.backup-list-actions button {
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
color: #b3b3b3;
font-size: 11px;
padding: 4px 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.backup-list-actions .backup-dl-btn:hover {
background: rgba(var(--accent-rgb), 0.25);
color: rgb(var(--accent-rgb));
}
.backup-list-actions .backup-restore-btn:hover {
background: rgba(255, 183, 77, 0.25);
color: #ffb74d;
}
.backup-list-actions .backup-delete-btn:hover {
background: rgba(244, 67, 54, 0.25);
color: #f44336;
}
.backup-list-container::-webkit-scrollbar {
width: 4px;
}
.backup-list-container::-webkit-scrollbar-track {
background: transparent;
}
.backup-list-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
/* Activity Feed */
.activity-feed-container {