diff --git a/web_server.py b/web_server.py index 3960c6f7..d1e169a0 100644 --- a/web_server.py +++ b/web_server.py @@ -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 == # =============================== diff --git a/webui/index.html b/webui/index.html index f7bea449..03434a4b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5566,6 +5566,34 @@ faster updates but more server load. + +
+

Database Maintenance

+ +
+ + Loading... +
+
+ + Loading... +
+
+ + Loading... +
+ +
+ + +
+
+ Compact Database rewrites the entire DB to reclaim unused space. Locks the database briefly — may take a minute on large databases.
+ Incremental Vacuum enables automatic page reclamation. Requires a one-time full compact to activate. After that, freed pages are reclaimed in small batches automatically. +
+ +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 02474737..2a2be056 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 {