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:
parent
cfa4a0c59f
commit
35fd7bb294
6 changed files with 439 additions and 1 deletions
|
|
@ -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
|
||||
103
web_server.py
103
web_server.py
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
BIN
webui/static/hydrabase.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue