Add Hydrabase dev UI and WebSocket support

Introduce a developer-only Hydrabase testing UI and backend WebSocket integration. Adds a simple dev-mode toggle (password 'hydratest') and new API endpoints (/api/dev-mode, /api/hydrabase/connect, /api/hydrabase/disconnect, /api/hydrabase/status, /api/hydrabase/send) that use websocket-client to connect/send raw JSON to a Hydrabase instance. Frontend changes include a Hydrabase nav/page, payload editors, response panel, dev-mode UI in Settings, associated JS handlers, CSS styling, and an icon asset. Also add websocket-client to requirements.
This commit is contained in:
Broque Thomas 2026-02-21 00:32:04 -08:00
parent cfa4a0c59f
commit 35fd7bb294
6 changed files with 439 additions and 1 deletions

View file

@ -39,4 +39,7 @@ lrclibapi>=0.3.1
asyncio-mqtt>=0.16.0
# Audio fingerprinting for download verification
pyacoustid>=1.3.0
pyacoustid>=1.3.0
# WebSocket client for Hydrabase connection
websocket-client>=1.7.0

View file

@ -2482,6 +2482,109 @@ def handle_settings():
except Exception as e:
return jsonify({"error": str(e)}), 500
dev_mode_enabled = False
@app.route('/api/dev-mode', methods=['GET', 'POST'])
def handle_dev_mode():
global dev_mode_enabled
if request.method == 'POST':
data = request.get_json()
if data.get('password') == 'hydratest':
dev_mode_enabled = True
print("🔧 Dev mode activated")
return jsonify({"success": True, "enabled": True})
return jsonify({"success": False, "error": "Invalid password"}), 401
return jsonify({"enabled": dev_mode_enabled})
# ── Hydrabase WebSocket Connection ──
_hydrabase_ws = None
_hydrabase_lock = threading.Lock()
@app.route('/api/hydrabase/connect', methods=['POST'])
def hydrabase_connect():
"""Connect to a Hydrabase instance via WebSocket."""
global _hydrabase_ws
if not dev_mode_enabled:
return jsonify({"success": False, "error": "Dev mode not active"}), 403
data = request.get_json()
url = data.get('url', '').strip()
api_key = data.get('api_key', '').strip()
if not url or not api_key:
return jsonify({"success": False, "error": "URL and API key required"}), 400
try:
import websocket
with _hydrabase_lock:
# Close existing connection if any
if _hydrabase_ws:
try:
_hydrabase_ws.close()
except:
pass
ws = websocket.create_connection(
url,
header={"x-api-key": api_key},
timeout=10
)
_hydrabase_ws = ws
print(f"🧪 [Hydrabase] Connected to {url}")
return jsonify({"success": True, "message": "Connected"})
except Exception as e:
print(f"⚠️ [Hydrabase] Connection failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/hydrabase/disconnect', methods=['POST'])
def hydrabase_disconnect():
"""Disconnect from Hydrabase."""
global _hydrabase_ws
with _hydrabase_lock:
if _hydrabase_ws:
try:
_hydrabase_ws.close()
except:
pass
_hydrabase_ws = None
print("🧪 [Hydrabase] Disconnected")
return jsonify({"success": True})
@app.route('/api/hydrabase/status')
def hydrabase_status():
"""Check if connected to Hydrabase."""
connected = _hydrabase_ws is not None and _hydrabase_ws.connected
return jsonify({"connected": connected})
@app.route('/api/hydrabase/send', methods=['POST'])
def hydrabase_send():
"""Send a raw JSON payload to Hydrabase and return the response."""
global _hydrabase_ws
if not dev_mode_enabled:
return jsonify({"success": False, "error": "Dev mode not active"}), 403
if not _hydrabase_ws or not _hydrabase_ws.connected:
return jsonify({"success": False, "error": "Not connected to Hydrabase"}), 400
data = request.get_json()
payload = data.get('payload')
if not payload:
return jsonify({"success": False, "error": "No payload provided"}), 400
try:
message = json.dumps(payload) if isinstance(payload, dict) else str(payload)
with _hydrabase_lock:
_hydrabase_ws.send(message)
response = _hydrabase_ws.recv()
try:
result = json.loads(response)
except json.JSONDecodeError:
result = response
print(f"🧪 [Hydrabase] Sent payload — got response")
return jsonify({"success": True, "data": result})
except Exception as e:
print(f"⚠️ [Hydrabase] Send failed: {e}")
with _hydrabase_lock:
try:
_hydrabase_ws.close()
except:
pass
_hydrabase_ws = None
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/settings/log-level', methods=['GET', 'POST'])
def handle_log_level():
"""Get or set the application log level"""

View file

@ -58,6 +58,10 @@
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
</button>
<button class="nav-button" data-page="hydrabase" id="hydrabase-nav" style="display: none;">
<span class="nav-icon"><img src="/static/hydrabase.png?v=1" class="hydrabase-icon"></span>
<span class="nav-text">Hydrabase</span>
</button>
</nav>
<!-- Spacer -->
@ -3298,6 +3302,19 @@
<div class="readonly-field" id="log-path-display">logs/app.log</div>
</div>
</div>
<!-- Developer Mode -->
<div class="settings-group">
<h3>🔧 Developer Mode</h3>
<div class="form-group">
<label>Password:</label>
<div style="display: flex; gap: 8px;">
<input type="password" id="dev-mode-password" placeholder="Enter dev password">
<button class="test-button" onclick="activateDevMode()">Activate</button>
</div>
<div id="dev-mode-status" style="margin-top: 8px; color: #888;">Inactive</div>
</div>
</div>
</div>
</div>
@ -3307,6 +3324,85 @@
</div>
</div>
</div>
<!-- Hydrabase Page (Dev Mode Only) -->
<div class="page" id="hydrabase-page">
<div class="page-header">
<h2><img src="/static/hydrabase.png?v=1" class="hydrabase-icon" style="width: 72px; height: 72px;"> Hydrabase</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<span id="hydra-connection-status" style="color: #888; font-size: 13px;">Disconnected</span>
<button class="test-button" id="hydra-connect-btn" onclick="hydrabaseToggleConnection()">Connect</button>
</div>
</div>
<!-- Connection Config -->
<div class="hydrabase-card" style="margin-bottom: 16px;">
<div style="display: flex; gap: 12px; align-items: end;">
<div style="flex: 2;">
<label style="font-size: 12px; color: #888; display: block; margin-bottom: 4px;">WebSocket URL</label>
<input type="text" id="hydra-ws-url" placeholder="ws://localhost:4545" value="ws://localhost:4545">
</div>
<div style="flex: 1;">
<label style="font-size: 12px; color: #888; display: block; margin-bottom: 4px;">API Key</label>
<input type="text" id="hydra-api-key" placeholder="Your Hydrabase API key">
</div>
</div>
</div>
<div class="hydrabase-container">
<!-- Left Panel — API Inputs -->
<div class="hydrabase-panel hydrabase-inputs">
<h3>API Calls</h3>
<div class="hydrabase-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h4 style="margin: 0;">Tracks</h4>
<button class="test-button" onclick="hydrabaseSendRaw('hydra-payload-track')">Send</button>
</div>
<textarea class="hydra-payload" id="hydra-payload-track" rows="4">{ "request": { "type": "track", "query": "" } }</textarea>
</div>
<div class="hydrabase-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h4 style="margin: 0;">Albums</h4>
<button class="test-button" onclick="hydrabaseSendRaw('hydra-payload-album')">Send</button>
</div>
<textarea class="hydra-payload" id="hydra-payload-album" rows="4">{ "request": { "type": "album", "query": "" } }</textarea>
</div>
<div class="hydrabase-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h4 style="margin: 0;">Artists</h4>
<button class="test-button" onclick="hydrabaseSendRaw('hydra-payload-artist')">Send</button>
</div>
<textarea class="hydra-payload" id="hydra-payload-artist" rows="4">{ "request": { "type": "artist", "query": "" } }</textarea>
</div>
<div class="hydrabase-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h4 style="margin: 0;">Discography</h4>
<button class="test-button" onclick="hydrabaseSendRaw('hydra-payload-discog')">Send</button>
</div>
<textarea class="hydra-payload" id="hydra-payload-discog" rows="4">{ "request": { "type": "discography", "query": "" } }</textarea>
</div>
<div class="hydrabase-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h4 style="margin: 0;">Album Tracks</h4>
<button class="test-button" onclick="hydrabaseSendRaw('hydra-payload-albumtracks')">Send</button>
</div>
<textarea class="hydra-payload" id="hydra-payload-albumtracks" rows="4">{ "request": { "type": "album_tracks", "query": "" } }</textarea>
</div>
</div>
<!-- Right Panel — API Responses -->
<div class="hydrabase-panel hydrabase-responses">
<h3>Response</h3>
<div class="hydrabase-response-area" id="hydra-response">API responses will appear here</div>
</div>
</div>
</div>
</div>
</div>

BIN
webui/static/hydrabase.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -596,6 +596,17 @@ async function loadPageData(pageId) {
await loadSettingsData();
await loadQualityProfile();
break;
case 'hydrabase':
// Check connection status on page load
try {
const hsResp = await fetch('/api/hydrabase/status');
const hsData = await hsResp.json();
_hydrabaseConnected = hsData.connected;
document.getElementById('hydra-connection-status').textContent = hsData.connected ? 'Connected' : 'Disconnected';
document.getElementById('hydra-connection-status').style.color = hsData.connected ? '#1ed760' : '#888';
document.getElementById('hydra-connect-btn').textContent = hsData.connected ? 'Disconnect' : 'Connect';
} catch (e) {}
break;
}
} catch (error) {
console.error(`Error loading ${pageId} data:`, error);
@ -1851,6 +1862,19 @@ async function loadSettingsData() {
console.error('Error loading log level:', error);
}
// Check dev mode status
try {
const devResponse = await fetch('/api/dev-mode');
const devData = await devResponse.json();
if (devData.enabled) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = '#1ed760';
document.getElementById('hydrabase-nav').style.display = '';
}
} catch (error) {
console.error('Error checking dev mode:', error);
}
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
@ -2199,6 +2223,131 @@ async function saveQualityProfile() {
// END QUALITY PROFILE FUNCTIONS
// ===============================
async function activateDevMode() {
const password = document.getElementById('dev-mode-password').value;
try {
const response = await fetch('/api/dev-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = '#1ed760';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('dev-mode-password').value = '';
showToast('Dev mode activated', 'success');
} else {
showToast('Invalid password', 'error');
}
} catch (e) {
showToast('Failed to activate dev mode', 'error');
}
}
// ── Hydrabase Functions ──
let _hydrabaseConnected = false;
async function hydrabaseToggleConnection() {
if (_hydrabaseConnected) {
await hydrabaseDisconnect();
} else {
await hydrabaseConnect();
}
}
async function hydrabaseConnect() {
const url = document.getElementById('hydra-ws-url').value.trim();
const apiKey = document.getElementById('hydra-api-key').value.trim();
if (!url || !apiKey) {
showToast('URL and API key required', 'error');
return;
}
const statusEl = document.getElementById('hydra-connection-status');
const btn = document.getElementById('hydra-connect-btn');
statusEl.textContent = 'Connecting...';
statusEl.style.color = '#f0ad4e';
try {
const response = await fetch('/api/hydrabase/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, api_key: apiKey })
});
const data = await response.json();
if (data.success) {
_hydrabaseConnected = true;
statusEl.textContent = 'Connected';
statusEl.style.color = '#1ed760';
btn.textContent = 'Disconnect';
showToast('Connected to Hydrabase', 'success');
} else {
statusEl.textContent = 'Failed';
statusEl.style.color = '#f44336';
showToast(data.error || 'Connection failed', 'error');
}
} catch (e) {
statusEl.textContent = 'Error';
statusEl.style.color = '#f44336';
showToast('Connection error', 'error');
}
}
async function hydrabaseDisconnect() {
try {
await fetch('/api/hydrabase/disconnect', { method: 'POST' });
} catch (e) {}
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
showToast('Disconnected from Hydrabase', 'success');
}
async function hydrabaseSendRaw(textareaId) {
const textarea = document.getElementById(textareaId);
const raw = textarea.value.trim();
if (!raw) {
showToast('Payload is empty', 'error');
return;
}
if (!_hydrabaseConnected) {
showToast('Not connected to Hydrabase', 'error');
return;
}
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
showToast('Invalid JSON payload', 'error');
return;
}
const responseArea = document.getElementById('hydra-response');
responseArea.textContent = 'Sending...';
try {
const response = await fetch('/api/hydrabase/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload })
});
const data = await response.json();
if (data.success) {
responseArea.textContent = JSON.stringify(data.data, null, 2);
} else {
responseArea.textContent = 'Error: ' + (data.error || 'Unknown error');
if (data.error && data.error.includes('Not connected')) {
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
}
}
} catch (e) {
responseArea.textContent = 'Error: ' + e.message;
}
}
async function saveSettings(quiet = false) {
// Validate file organization templates before saving
const validationErrors = validateFileOrganizationTemplates();

View file

@ -23675,4 +23675,91 @@ body {
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(244, 67, 54, 0.3);
}
/* =====================================
HYDRABASE PAGE
===================================== */
.hydrabase-container {
display: flex;
gap: 24px;
height: calc(100% - 80px);
}
.hydrabase-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.hydrabase-panel h3 {
margin: 0 0 4px 0;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.hydrabase-card {
background: linear-gradient(135deg, rgba(35, 35, 35, 0.6), rgba(20, 20, 20, 0.8));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
}
.hydrabase-card h4 {
margin: 0 0 10px 0;
color: #ccc;
font-size: 14px;
font-weight: 500;
}
.hydrabase-input-group {
display: flex;
gap: 8px;
}
.hydrabase-input-group input {
flex: 1;
}
.hydrabase-icon {
width: 18px;
height: 18px;
filter: invert(1);
vertical-align: middle;
}
.hydra-payload {
width: 100%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 10px;
color: #ccc;
font-family: 'Courier New', monospace;
font-size: 12px;
resize: vertical;
box-sizing: border-box;
}
.hydra-payload:focus {
border-color: rgba(29, 185, 84, 0.4);
outline: none;
}
.hydrabase-response-area {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #aaa;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}