Add Backup Manager dashboard tool card with list, download, restore & delete
This commit is contained in:
parent
b1cb9f9964
commit
266d044797
4 changed files with 400 additions and 33 deletions
116
web_server.py
116
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/<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 ==
|
||||
# ===============================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue