Add database maintenance UI with VACUUM and incremental vacuum

Settings > Advanced now shows database size, free pages, and auto-
vacuum mode. Two actions: Compact Database (full VACUUM to reclaim
dead space) and Enable Incremental Vacuum (one-time setup for
automatic page reclamation). Both have confirmation dialogs warning
about lock time on large databases. Info refreshes on Advanced tab
switch and after each operation.
This commit is contained in:
Broque Thomas 2026-04-06 22:57:52 -07:00
parent 88890f816f
commit 23b80a0077
3 changed files with 221 additions and 0 deletions

View file

@ -24126,6 +24126,116 @@ def download_backup_endpoint(filename):
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == DATABASE MAINTENANCE ==
# ===============================
@app.route('/api/database/maintenance/info', methods=['GET'])
def database_maintenance_info():
"""Get database size, free pages, and auto_vacuum mode."""
try:
import sqlite3
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute('PRAGMA page_count'); total_pages = c.fetchone()[0]
c.execute('PRAGMA freelist_count'); free_pages = c.fetchone()[0]
c.execute('PRAGMA page_size'); page_size = c.fetchone()[0]
c.execute('PRAGMA auto_vacuum'); auto_vacuum = c.fetchone()[0]
conn.close()
total_bytes = total_pages * page_size
free_bytes = free_pages * page_size
auto_vacuum_labels = {0: 'None', 1: 'Full', 2: 'Incremental'}
return jsonify({
'success': True,
'total_size': total_bytes,
'total_size_display': f'{total_bytes / 1024 / 1024:.1f} MB',
'free_pages': free_pages,
'free_size': free_bytes,
'free_size_display': f'{free_bytes / 1024 / 1024:.1f} MB',
'bloat_percent': round(free_pages / total_pages * 100, 1) if total_pages > 0 else 0,
'auto_vacuum': auto_vacuum,
'auto_vacuum_label': auto_vacuum_labels.get(auto_vacuum, 'Unknown'),
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/database/maintenance/vacuum', methods=['POST'])
def database_vacuum():
"""Run VACUUM to compact the database. Locks DB during operation."""
try:
import sqlite3, time
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
# Get size before
size_before = os.path.getsize(db_path)
conn = sqlite3.connect(db_path)
start = time.time()
conn.execute('VACUUM')
elapsed = time.time() - start
conn.close()
size_after = os.path.getsize(db_path)
saved = size_before - size_after
logger.info(f"Database VACUUM completed in {elapsed:.1f}s — saved {saved / 1024 / 1024:.1f} MB")
return jsonify({
'success': True,
'elapsed_seconds': round(elapsed, 1),
'size_before': size_before,
'size_after': size_after,
'saved_bytes': saved,
'saved_display': f'{saved / 1024 / 1024:.1f} MB',
})
except Exception as e:
logger.error(f"Database VACUUM failed: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/database/maintenance/enable-incremental-vacuum', methods=['POST'])
def enable_incremental_vacuum():
"""Enable incremental auto_vacuum. Requires a full VACUUM to activate."""
try:
import sqlite3, time
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute('PRAGMA auto_vacuum')
current = c.fetchone()[0]
if current == 2:
conn.close()
return jsonify({'success': True, 'message': 'Incremental vacuum is already enabled', 'already_enabled': True})
size_before = os.path.getsize(db_path)
# Set incremental mode and VACUUM to activate it
c.execute('PRAGMA auto_vacuum = INCREMENTAL')
start = time.time()
conn.execute('VACUUM')
elapsed = time.time() - start
conn.close()
size_after = os.path.getsize(db_path)
saved = size_before - size_after
logger.info(f"Incremental auto_vacuum enabled in {elapsed:.1f}s — saved {saved / 1024 / 1024:.1f} MB")
return jsonify({
'success': True,
'message': 'Incremental vacuum enabled',
'elapsed_seconds': round(elapsed, 1),
'saved_display': f'{saved / 1024 / 1024:.1f} MB',
})
except Exception as e:
logger.error(f"Failed to enable incremental vacuum: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ===============================
# == METADATA CACHE API ==
# ===============================

View file

@ -5566,6 +5566,34 @@
faster updates but more server load.</div>
</div>
<!-- Database Maintenance -->
<div class="settings-group" data-stg="advanced">
<h3>Database Maintenance</h3>
<div class="form-group" id="db-maintenance-info">
<label>Database Size:</label>
<span id="db-size-display" class="readonly-field">Loading...</span>
</div>
<div class="form-group">
<label>Free Pages:</label>
<span id="db-freepages-display" class="readonly-field">Loading...</span>
</div>
<div class="form-group">
<label>Auto-Vacuum Mode:</label>
<span id="db-autovacuum-display" class="readonly-field">Loading...</span>
</div>
<div class="form-actions" style="flex-wrap: wrap;">
<button class="test-button" id="db-vacuum-btn" onclick="runDatabaseVacuum()">Compact Database (VACUUM)</button>
<button class="test-button" id="db-incvacuum-btn" onclick="enableIncrementalVacuum()">Enable Incremental Vacuum</button>
</div>
<div class="help-text">
<strong>Compact Database</strong> rewrites the entire DB to reclaim unused space. Locks the database briefly — may take a minute on large databases.<br>
<strong>Incremental Vacuum</strong> enables automatic page reclamation. Requires a one-time full compact to activate. After that, freed pages are reclaimed in small batches automatically.
</div>
<div id="db-vacuum-status" style="display: none; margin-top: 8px; padding: 8px 12px; border-radius: 8px; font-size: 0.85em;"></div>
</div>
<!-- Content Filter Settings -->
<div class="settings-group" data-stg="library">

View file

@ -5549,6 +5549,10 @@ function switchSettingsTab(tab) {
if (typeof updateDownloadSourceUI === 'function') {
try { updateDownloadSourceUI(); } catch(e) {}
}
// Load DB maintenance info when switching to Advanced tab
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch(e) {}
}
}
function toggleStgService(el) {
@ -6589,6 +6593,85 @@ async function toggleHydrabaseFromSettings() {
}
}
// ── Database Maintenance ──
async function loadDbMaintenanceInfo() {
try {
const resp = await fetch('/api/database/maintenance/info');
const data = await resp.json();
if (!data.success) return;
const sizeEl = document.getElementById('db-size-display');
const freeEl = document.getElementById('db-freepages-display');
const vacEl = document.getElementById('db-autovacuum-display');
if (sizeEl) sizeEl.textContent = data.total_size_display;
if (freeEl) freeEl.textContent = data.free_pages > 0
? `${data.free_pages.toLocaleString()} (${data.free_size_display} reclaimable)`
: 'None — database is fully compacted';
if (vacEl) vacEl.textContent = data.auto_vacuum_label;
// Hide enable button if already incremental
const incBtn = document.getElementById('db-incvacuum-btn');
if (incBtn && data.auto_vacuum === 2) {
incBtn.textContent = 'Incremental Vacuum Enabled';
incBtn.disabled = true;
incBtn.style.opacity = '0.5';
}
} catch (e) { console.error('Error loading DB maintenance info:', e); }
}
async function runDatabaseVacuum() {
const btn = document.getElementById('db-vacuum-btn');
const status = document.getElementById('db-vacuum-status');
if (!confirm('This will compact the database by rewriting it. The database will be locked during this operation. For large databases this may take over a minute. Continue?')) return;
btn.disabled = true;
btn.textContent = 'Compacting...';
if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Running VACUUM — this may take a while...'; }
try {
const resp = await fetch('/api/database/maintenance/vacuum', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(`Database compacted in ${data.elapsed_seconds}s — saved ${data.saved_display}`, 'success');
if (status) { status.style.color = '#4caf50'; status.textContent = `Done in ${data.elapsed_seconds}s. Saved ${data.saved_display}.`; }
loadDbMaintenanceInfo();
} else {
showToast('Vacuum failed: ' + (data.error || 'Unknown error'), 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); }
}
} catch (e) {
showToast('Vacuum failed: ' + e.message, 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; }
} finally {
btn.disabled = false;
btn.textContent = 'Compact Database (VACUUM)';
}
}
async function enableIncrementalVacuum() {
const btn = document.getElementById('db-incvacuum-btn');
const status = document.getElementById('db-vacuum-status');
if (!confirm('This will enable incremental vacuum mode. It requires a one-time full VACUUM to activate, which locks the database and may take over a minute on large databases. Continue?')) return;
btn.disabled = true;
btn.textContent = 'Enabling...';
if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Enabling incremental vacuum — this may take a while...'; }
try {
const resp = await fetch('/api/database/maintenance/enable-incremental-vacuum', { method: 'POST' });
const data = await resp.json();
if (data.success) {
const msg = data.already_enabled ? 'Already enabled' : `Enabled in ${data.elapsed_seconds}s — saved ${data.saved_display}`;
showToast(msg, 'success');
if (status) { status.style.color = '#4caf50'; status.textContent = msg; }
loadDbMaintenanceInfo();
} else {
showToast('Failed: ' + (data.error || 'Unknown error'), 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); }
}
} catch (e) {
showToast('Failed: ' + e.message, 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; }
} finally {
btn.disabled = false;
btn.textContent = 'Enable Incremental Vacuum';
}
}
async function activateDevMode() {
const password = document.getElementById('dev-mode-password').value;
try {