diff --git a/web_server.py b/web_server.py index 1f8393dd..bd55d343 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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//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//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 == # =============================== diff --git a/webui/index.html b/webui/index.html index c564b839..970cfb57 100644 --- a/webui/index.html +++ b/webui/index.html @@ -502,12 +502,8 @@

Database Updater

-
- - -
+

Last Full Refresh: Never

@@ -748,6 +744,37 @@ request

+ +
+
+

Backup Manager

+ +
+

Create, download, restore and manage database backups

+
+
+ Last Backup: + Never +
+
+ Backups: + 0 +
+
+ Latest Size: + +
+
+ DB Size: + +
+
+
+ +
+
+
diff --git a/webui/static/script.js b/webui/static/script.js index 1b79abb2..c2d760fa 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 `
+
+ ${escapeHtml(dateStr)} + ${b.size_mb} MB +
+
+ + + +
+
`; + }).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 = {

Combining with notifications

You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.

` + }, + 'backup-manager': { + title: 'Backup Manager', + content: ` +

What does this tool do?

+

The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.

+ +

Features

+
    +
  • Backup Now: Create an instant backup of the current database using SQLite's hot-copy API
  • +
  • Download: Download any backup file to your local machine
  • +
  • Restore: Roll back the database to a previous backup state
  • +
  • Delete: Remove old backups you no longer need
  • +
+ +

Auto-Backups

+

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).

+ +

Restore Safety

+

When you restore from a backup, a safety backup of your current database is created first. This means you can always undo a restore if something goes wrong.

+ +

Stats Explained

+
    +
  • Last Backup: When the most recent backup was created
  • +
  • Backups: Total number of backup files available
  • +
  • Latest Size: Size of the most recent backup
  • +
  • DB Size: Current size of the live database
  • +
+ ` } }; @@ -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'; diff --git a/webui/static/style.css b/webui/static/style.css index 860fd017..1ce049c7 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 {