Add Hydrabase P2P mirror worker

Introduce a Hydrabase P2P mirror worker and integrate it into the web UI and server flows. Adds core/hydrabase_worker.py: a background thread with a capped queue (1000), enqueue API, rate limiting, basic stats (sent/dropped/errors), and logic to send JSON requests over a provided WebSocket (responses received and discarded). Integrates the worker into web_server.py (import, startup init, status/pause/resume endpoints, and enqueues queries from multiple search endpoints when dev mode is enabled). Adds UI elements, JavaScript polling/toggle logic, and CSS styling for a Hydrabase status button in webui (index.html, static/script.js, static/style.css) to display and control worker state.
pull/153/head
Broque Thomas 3 months ago
parent 5f558106bf
commit 49a6c58ea8

@ -0,0 +1,145 @@
"""
Hydrabase P2P Mirror Worker
Background worker that intercepts search queries and mirrors them to the
Hydrabase P2P network via WebSocket. Fire-and-forget responses are received
(required by protocol) but discarded. Only processes items when the Hydrabase
WebSocket is connected; items are silently dropped when not connected.
"""
import json
import logging
import queue
import threading
import time
logger = logging.getLogger(__name__)
class HydrabaseWorker:
def __init__(self, get_ws_and_lock):
"""
Args:
get_ws_and_lock: Callable returning (ws, lock) tuple for the
Hydrabase WebSocket connection.
"""
self.get_ws_and_lock = get_ws_and_lock
# Worker state
self.running = False
self.paused = False
self.should_stop = False
self.thread = None
# Queue with cap
self.queue = queue.Queue(maxsize=1000)
# Statistics
self.stats = {
'sent': 0,
'dropped': 0,
'errors': 0
}
def start(self):
if self.running:
return
self.running = True
self.should_stop = False
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Hydrabase P2P mirror worker started")
def stop(self):
if not self.running:
return
self.should_stop = True
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("Hydrabase P2P mirror worker stopped")
def pause(self):
if not self.running:
return
self.paused = True
def resume(self):
if not self.running:
return
self.paused = False
def enqueue(self, query, query_type):
"""Non-blocking enqueue. Drops oldest item if queue is full."""
if not query or not self.running:
return
item = {'query': query, 'type': query_type}
try:
self.queue.put_nowait(item)
except queue.Full:
# Drop oldest, then add new
try:
self.queue.get_nowait()
except queue.Empty:
pass
try:
self.queue.put_nowait(item)
except queue.Full:
pass
def get_stats(self):
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
return {
'enabled': True,
'running': is_actually_running and not self.paused,
'paused': self.paused,
'queue_size': self.queue.qsize(),
'stats': self.stats.copy()
}
def _run(self):
while not self.should_stop:
try:
if self.paused:
time.sleep(1)
continue
# Non-blocking dequeue with timeout
try:
item = self.queue.get(timeout=1)
except queue.Empty:
continue
self._process_item(item)
time.sleep(0.5) # Rate limit
except Exception as e:
logger.error(f"Error in Hydrabase worker loop: {e}")
self.stats['errors'] += 1
time.sleep(2)
def _process_item(self, item):
ws, lock = self.get_ws_and_lock()
if ws is None:
self.stats['dropped'] += 1
return
payload = json.dumps({
'request': {
'type': item['type'],
'query': item['query']
}
})
try:
with lock:
if not ws.connected:
self.stats['dropped'] += 1
return
ws.send(payload)
ws.recv() # Required by protocol, response discarded
self.stats['sent'] += 1
except Exception as e:
logger.debug(f"Hydrabase send failed: {e}")
self.stats['dropped'] += 1

@ -71,6 +71,7 @@ from beatport_unified_scraper import BeatportUnifiedScraper
from core.musicbrainz_worker import MusicBrainzWorker
from core.audiodb_worker import AudioDBWorker
from core.deezer_worker import DeezerWorker
from core.hydrabase_worker import HydrabaseWorker
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
@ -3941,6 +3942,12 @@ def enhanced_search():
logger.info(f"Enhanced search initiated for: '{query}'")
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
hydrabase_worker.enqueue(query, 'album')
hydrabase_worker.enqueue(query, 'artist')
try:
# Search local database for artists
database = get_database()
@ -5578,6 +5585,10 @@ def get_artist_discography(artist_id):
# Get optional artist name for fallback searches
artist_name = request.args.get('artist_name', '')
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled and artist_name:
hydrabase_worker.enqueue(artist_name, 'discography')
# Determine which source to use
spotify_available = spotify_client and spotify_client.is_spotify_authenticated()
@ -6791,7 +6802,11 @@ def search_match():
if not query:
return jsonify({"results": []})
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, context)
if context == 'artist':
# Search for artists
artist_matches = spotify_client.search_artists(query, limit=8)
@ -16423,6 +16438,10 @@ def search_spotify():
if not query:
return jsonify({"error": "Query parameter 'q' is required"}), 400
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, search_type)
# Search using spotify_client
tracks = spotify_client.search_tracks(query, limit=limit)
@ -16456,6 +16475,10 @@ def search_spotify_tracks():
if not query:
return jsonify({"error": "Query parameter is required"}), 400
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
# Search using spotify_client
tracks = spotify_client.search_tracks(query, limit=limit)
@ -16487,6 +16510,10 @@ def search_itunes_tracks():
if not query:
return jsonify({"error": "Query parameter is required"}), 400
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
# Search using iTunes client
itunes_client = iTunesClient()
tracks = itunes_client.search_tracks(query, limit=limit)
@ -21804,6 +21831,10 @@ def search_artists_for_playlist():
if not query:
return jsonify({"success": False, "error": "Query required"}), 400
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'artist')
# Search Spotify for artists
results = spotify_client.sp.search(q=query, type='artist', limit=10)
@ -26256,6 +26287,76 @@ def deezer_resume():
# ================================================================================================
# ================================================================================================
# HYDRABASE P2P MIRROR WORKER
# ================================================================================================
# --- Hydrabase Worker Initialization ---
hydrabase_worker = None
try:
def _get_hydrabase_ws_and_lock():
return (_hydrabase_ws, _hydrabase_lock)
hydrabase_worker = HydrabaseWorker(get_ws_and_lock=_get_hydrabase_ws_and_lock)
hydrabase_worker.start()
print("✅ Hydrabase P2P mirror worker initialized and started")
except Exception as e:
print(f"⚠️ Hydrabase worker initialization failed: {e}")
hydrabase_worker = None
# --- Hydrabase Worker API Endpoints ---
@app.route('/api/hydrabase-worker/status', methods=['GET'])
def hydrabase_worker_status():
"""Get Hydrabase P2P mirror worker status for UI polling"""
try:
if hydrabase_worker is None:
return jsonify({
'enabled': False,
'running': False,
'paused': False,
'queue_size': 0,
'stats': {'sent': 0, 'dropped': 0, 'errors': 0}
}), 200
status = hydrabase_worker.get_stats()
return jsonify(status), 200
except Exception as e:
logger.error(f"Error getting Hydrabase worker status: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/hydrabase-worker/pause', methods=['POST'])
def hydrabase_worker_pause():
"""Pause Hydrabase P2P mirror worker"""
try:
if hydrabase_worker is None:
return jsonify({'error': 'Hydrabase worker not initialized'}), 400
hydrabase_worker.pause()
logger.info("Hydrabase worker paused via UI")
return jsonify({'status': 'paused'}), 200
except Exception as e:
logger.error(f"Error pausing Hydrabase worker: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/hydrabase-worker/resume', methods=['POST'])
def hydrabase_worker_resume():
"""Resume Hydrabase P2P mirror worker"""
try:
if hydrabase_worker is None:
return jsonify({'error': 'Hydrabase worker not initialized'}), 400
hydrabase_worker.resume()
logger.info("Hydrabase worker resumed via UI")
return jsonify({'status': 'running'}), 200
except Exception as e:
logger.error(f"Error resuming Hydrabase worker: {e}")
return jsonify({'error': str(e)}), 500
# ================================================================================================
# END HYDRABASE P2P MIRROR WORKER
# ================================================================================================
# ================================================================================================
# IMPORT / STAGING SYSTEM
# ================================================================================================
@ -26430,6 +26531,10 @@ def import_search_albums():
if not query:
return jsonify({'success': False, 'error': 'Missing query parameter'}), 400
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'album')
limit = min(int(request.args.get('limit', 12)), 50)
albums = spotify_client.search_albums(query, limit=limit)

@ -246,6 +246,22 @@
</div>
</div>
</div>
<!-- Hydrabase P2P Mirror Status Icon -->
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">
<img src="/static/hydrabase.png" alt="Hydrabase" class="hydrabase-worker-logo">
<div class="hydrabase-spinner"></div>
</button>
<!-- Hydrabase Worker Tooltip -->
<div class="hydrabase-tooltip" id="hydrabase-tooltip">
<div class="hydrabase-tooltip-content">
<div class="hydrabase-tooltip-header">Hydrabase P2P Mirror</div>
<div class="hydrabase-tooltip-body" id="hydrabase-tooltip-body">
<div class="tooltip-status">Status: <span id="hydrabase-tooltip-status">Active</span></div>
</div>
</div>
</div>
</div>
<button class="import-button" id="import-button" onclick="openImportModal()"
title="Import Music from Staging">
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"

@ -1870,6 +1870,7 @@ async function loadSettingsData() {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = '#1ed760';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
}
} catch (error) {
console.error('Error checking dev mode:', error);
@ -2236,6 +2237,7 @@ async function activateDevMode() {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = '#1ed760';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
document.getElementById('dev-mode-password').value = '';
showToast('Dev mode activated', 'success');
} else {
@ -35901,6 +35903,76 @@ if (document.readyState === 'loading') {
}
}
// ===================================================================
// HYDRABASE P2P MIRROR WORKER
// ===================================================================
async function updateHydrabaseStatus() {
try {
const response = await fetch('/api/hydrabase-worker/status');
if (!response.ok) return;
const data = await response.json();
const button = document.getElementById('hydrabase-button');
if (!button) return;
button.classList.remove('active', 'paused');
if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
// Update tooltip
const statusEl = document.getElementById('hydrabase-tooltip-status');
if (statusEl) {
if (data.paused) {
statusEl.textContent = 'Paused';
statusEl.style.color = '#ffc107';
} else if (data.running) {
statusEl.textContent = 'Active';
statusEl.style.color = '#ffffff';
} else {
statusEl.textContent = 'Stopped';
statusEl.style.color = '#ff5252';
}
}
} catch (error) {
// Silently ignore — worker may not be available
}
}
async function toggleHydrabaseWorker() {
const button = document.getElementById('hydrabase-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume';
try {
await fetch(endpoint, { method: 'POST' });
await updateHydrabaseStatus();
} catch (error) {
console.error('Error toggling Hydrabase worker:', error);
}
}
// Initialize Hydrabase UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
});
} else {
const button = document.getElementById('hydrabase-button');
if (button) {
button.addEventListener('click', toggleHydrabaseWorker);
updateHydrabaseStatus();
setInterval(updateHydrabaseStatus, 2000);
}
}
// ===================================================================
// IMPORT / STAGING SYSTEM
// ===================================================================

@ -22662,6 +22662,171 @@ body {
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* ========================================
HYDRABASE P2P MIRROR WORKER BUTTON
======================================== */
.hydrabase-button-container {
position: relative;
display: inline-block;
margin-right: 12px;
}
.hydrabase-button {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.25);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
overflow: visible;
padding: 0;
}
.hydrabase-button:hover {
transform: scale(1.05);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.15);
}
.hydrabase-button:active {
transform: scale(0.95);
}
.hydrabase-worker-logo {
width: 24px;
height: 24px;
opacity: 0.85;
filter: brightness(0) invert(1);
transition: opacity 0.3s ease;
z-index: 1;
}
.hydrabase-spinner {
position: absolute;
top: -2px;
left: -2px;
width: 44px;
height: 44px;
border: 2px solid transparent;
border-top-color: rgba(255, 255, 255, 0.6);
border-right-color: rgba(255, 255, 255, 0.3);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
/* Active state — white spinning */
.hydrabase-button.active .hydrabase-spinner {
opacity: 1;
animation: hydrabase-spin 1.2s linear infinite;
}
.hydrabase-button.active {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.10) 100%);
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 20px rgba(255, 255, 255, 0.15), inset 0 0 10px rgba(255, 255, 255, 0.05);
}
.hydrabase-button.active .hydrabase-worker-logo {
opacity: 1;
}
/* Paused state — yellow/orange */
.hydrabase-button.paused {
background: linear-gradient(135deg, rgba(255, 193, 7, 0.18) 0%, rgba(255, 152, 0, 0.10) 100%);
border-color: rgba(255, 193, 7, 0.45);
box-shadow: 0 0 15px rgba(255, 193, 7, 0.1);
}
.hydrabase-button.paused .hydrabase-worker-logo {
opacity: 0.7;
filter: brightness(0) invert(1) sepia(1) saturate(5) hue-rotate(15deg);
}
.hydrabase-button.paused .hydrabase-spinner {
opacity: 0;
}
@keyframes hydrabase-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Tooltip */
.hydrabase-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
pointer-events: none;
}
.hydrabase-button-container:hover .hydrabase-tooltip {
opacity: 1;
visibility: visible;
}
.hydrabase-tooltip-content {
min-width: 180px;
background: rgba(30, 30, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
padding: 12px 14px;
backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.hydrabase-tooltip-header {
font-weight: 600;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
letter-spacing: 0.3px;
}
.hydrabase-tooltip-body {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
}
.hydrabase-tooltip-content::before {
content: '';
position: absolute;
left: 50%;
top: -8px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(255, 255, 255, 0.25);
}
.hydrabase-tooltip-content::after {
content: '';
position: absolute;
left: 50%;
top: -7px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* MusicBrainz Integration */
.release-card {
position: relative !important;

Loading…
Cancel
Save