Integrate Hydrabase P2P metadata client

Add Hydrabase support as an optional/dev metadata source and comparison tool.

- Add core/hydrabase_client.py: synchronous Hydrabase WebSocket client that normalizes results to Track/Artist/Album types and exposes raw access.
- Update config/settings.py: add hydrabase settings (url, api_key, auto_connect) and getter.
- Update web_server.py: integrate HydrabaseClient, initialize client alongside the existing HydrabaseWorker, add auto-reconnect using saved config, persist credentials on connect/disconnect, add endpoints for status and stored comparisons, background comparison runner (Hydrabase vs Spotify vs iTunes), and adapt multiple search endpoints to optionally use Hydrabase as the primary metadata source with fallbacks.
- Update web UI (webui/index.html, webui/static/script.js, webui/static/style.css): add network stats and source comparison UI, pre-fill saved credentials, show peer count, load/display comparisons, update disconnect behavior to disable dev mode, and add Hydrabase badge styling.

Behavioral notes: when dev mode + Hydrabase are active, searches can be served from Hydrabase and comparisons to Spotify/iTunes are run in background; when Hydrabase fails the code falls back to Spotify/iTunes. Saved Hydrabase credentials are persisted for auto-reconnect; disconnect disables dev mode and auto_connect.

Files touched: config/settings.py, core/hydrabase_client.py, web_server.py, webui/index.html, webui/static/script.js, webui/static/style.css.
This commit is contained in:
Broque Thomas 2026-02-24 08:11:48 -08:00
parent 1c34967fd3
commit 1283041836
6 changed files with 590 additions and 111 deletions

View file

@ -230,6 +230,11 @@ class ConfigManager:
},
"m3u_export": {
"enabled": False
},
"hydrabase": {
"url": "",
"api_key": "",
"auto_connect": False
}
}
@ -332,6 +337,9 @@ class ConfigManager:
def get_soulseek_config(self) -> Dict[str, str]:
return self.get('soulseek', {})
def get_hydrabase_config(self) -> Dict[str, str]:
return self.get('hydrabase', {})
def get_settings(self) -> Dict[str, Any]:
return self.get('settings', {})

178
core/hydrabase_client.py Normal file
View file

@ -0,0 +1,178 @@
"""
Hydrabase P2P metadata client.
Sends search requests over a shared WebSocket connection and returns
results normalized to the same dataclass types used by SpotifyClient
and iTunesClient (Track, Artist, Album).
"""
import json
import logging
import re
from typing import List, Optional, Callable, Tuple
from core.itunes_client import Track, Artist, Album
logger = logging.getLogger(__name__)
class HydrabaseClient:
"""
Synchronous metadata client that queries the Hydrabase P2P network.
Shares the WebSocket connection and lock with HydrabaseWorker.
All search methods block until a response is received (with timeout).
"""
def __init__(self, get_ws_and_lock: Callable[[], Tuple]):
"""
Args:
get_ws_and_lock: Callable returning (ws, lock) tuple.
Same callable used by HydrabaseWorker.
"""
self.get_ws_and_lock = get_ws_and_lock
self.timeout = 15 # seconds
self.last_peer_count = None
self.last_peer_count_time = None
def is_connected(self) -> bool:
ws, lock = self.get_ws_and_lock()
if ws is None:
return False
try:
return ws.connected
except Exception:
return False
def _send_and_recv(self, request_type: str, query: str) -> Optional[list]:
"""Send a search request and return the response array."""
ws, lock = self.get_ws_and_lock()
if ws is None:
return None
try:
if not ws.connected:
return None
except Exception:
return None
payload = json.dumps({
'request': {
'type': request_type,
'query': query
}
})
try:
with lock:
ws.settimeout(self.timeout)
ws.send(payload)
raw = ws.recv()
data = json.loads(raw)
# Check for peer_count in response (future stats messages)
if isinstance(data, dict) and 'peer_count' in data:
import time
self.last_peer_count = data['peer_count']
self.last_peer_count_time = time.time()
# Handle various response shapes
if isinstance(data, list):
return data
if isinstance(data, dict) and 'results' in data:
return data['results']
if isinstance(data, dict) and 'data' in data:
result = data['data']
return result if isinstance(result, list) else [result]
return [data] if data else []
except Exception as e:
logger.error(f"Hydrabase query failed ({request_type}, '{query}'): {e}")
return None
@staticmethod
def _normalize_release_date(date_str: str) -> str:
"""Strip time portion from ISO dates like '1995-01-01T08:00:00Z' -> '1995-01-01'."""
if not date_str:
return date_str
# Match YYYY-MM-DD at the start, discard the rest
match = re.match(r'(\d{4}(?:-\d{2}(?:-\d{2})?)?)', date_str)
return match.group(1) if match else date_str
# ==================== Track Methods ====================
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
results = self._send_and_recv('track', query)
if not results:
return []
tracks = []
for item in results[:limit]:
try:
tracks.append(Track(
id=str(item.get('id', '')),
name=item.get('name', ''),
artists=item.get('artists', []),
album=item.get('album', ''),
duration_ms=item.get('duration_ms', 0),
popularity=item.get('popularity', 0),
preview_url=item.get('preview_url'),
external_urls=item.get('external_urls'),
image_url=item.get('image_url'),
release_date=self._normalize_release_date(item.get('release_date', ''))
))
except Exception as e:
logger.debug(f"Skipping malformed Hydrabase track: {e}")
return tracks
# ==================== Artist Methods ====================
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
results = self._send_and_recv('artist', query)
if not results:
return []
artists = []
for item in results[:limit]:
try:
artists.append(Artist(
id=str(item.get('id', '')),
name=item.get('name', ''),
popularity=item.get('popularity', 0),
genres=item.get('genres', []),
followers=item.get('followers', 0),
image_url=item.get('image_url'),
external_urls=item.get('external_urls')
))
except Exception as e:
logger.debug(f"Skipping malformed Hydrabase artist: {e}")
return artists
# ==================== Album Methods ====================
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
results = self._send_and_recv('album', query)
if not results:
return []
albums = []
for item in results[:limit]:
try:
albums.append(Album(
id=str(item.get('id', '')),
name=item.get('name', ''),
artists=item.get('artists', []),
release_date=self._normalize_release_date(item.get('release_date', '')),
total_tracks=item.get('total_tracks', 0),
album_type=item.get('album_type', 'album'),
image_url=item.get('image_url'),
external_urls=item.get('external_urls')
))
except Exception as e:
logger.debug(f"Skipping malformed Hydrabase album: {e}")
return albums
# ==================== Raw access (for comparison) ====================
def search_raw(self, query: str, search_type: str) -> Optional[list]:
"""Return raw Hydrabase results without normalization (for comparison UI)."""
return self._send_and_recv(search_type, query)

View file

@ -74,6 +74,7 @@ from core.deezer_worker import DeezerWorker
from core.spotify_worker import SpotifyWorker
from core.itunes_worker import iTunesWorker
from core.hydrabase_worker import HydrabaseWorker
from core.hydrabase_client import HydrabaseClient
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
@ -2526,6 +2527,86 @@ def handle_dev_mode():
_hydrabase_ws = None
_hydrabase_lock = threading.Lock()
# ── Hydrabase Comparison Store ──
import collections as _collections
_hydrabase_comparisons = _collections.OrderedDict()
_COMPARISON_MAX_ENTRIES = 50
_comparison_lock = threading.Lock()
def _is_hydrabase_active():
"""Check if Hydrabase should be used as the primary metadata source.
Returns False when dev mode is off no behavior change for normal users."""
try:
return (dev_mode_enabled
and hydrabase_client is not None
and hydrabase_client.is_connected())
except NameError:
return False
def _run_background_comparison(query):
"""Run Spotify + iTunes searches in background and store for comparison."""
def _worker():
try:
result = {'timestamp': time.time(), 'query': query}
# Hydrabase raw results (already fetched for the primary search)
hydra_data = {'tracks': 0, 'artists': 0, 'albums': 0}
if hydrabase_client and hydrabase_client.is_connected():
raw_t = hydrabase_client.search_raw(query, 'track')
raw_ar = hydrabase_client.search_raw(query, 'artist')
raw_al = hydrabase_client.search_raw(query, 'album')
hydra_data = {
'tracks': len(raw_t) if raw_t else 0,
'artists': len(raw_ar) if raw_ar else 0,
'albums': len(raw_al) if raw_al else 0
}
result['hydrabase'] = hydra_data
# Spotify results
spotify_data = {'tracks': 0, 'artists': 0, 'albums': 0}
if spotify_client and spotify_client.is_authenticated():
try:
s_tracks = spotify_client.search_tracks(query, limit=10)
s_artists = spotify_client.search_artists(query, limit=10)
s_albums = spotify_client.search_albums(query, limit=10)
spotify_data = {
'tracks': len(s_tracks),
'artists': len(s_artists),
'albums': len(s_albums)
}
except Exception as e:
logger.debug(f"Comparison Spotify search failed: {e}")
result['spotify'] = spotify_data
# iTunes results
itunes_data = {'tracks': 0, 'artists': 0, 'albums': 0}
try:
from core.itunes_client import iTunesClient
itunes = iTunesClient()
i_tracks = itunes.search_tracks(query, limit=10)
i_artists = itunes.search_artists(query, limit=10)
i_albums = itunes.search_albums(query, limit=10)
itunes_data = {
'tracks': len(i_tracks),
'artists': len(i_artists),
'albums': len(i_albums)
}
except Exception as e:
logger.debug(f"Comparison iTunes search failed: {e}")
result['itunes'] = itunes_data
with _comparison_lock:
_hydrabase_comparisons[query] = result
while len(_hydrabase_comparisons) > _COMPARISON_MAX_ENTRIES:
_hydrabase_comparisons.popitem(last=False)
logger.info(f"Background comparison stored for '{query}': H={hydra_data}, S={spotify_data}, I={itunes_data}")
except Exception as e:
logger.error(f"Background comparison failed for '{query}': {e}")
threading.Thread(target=_worker, daemon=True).start()
@app.route('/api/hydrabase/connect', methods=['POST'])
def hydrabase_connect():
"""Connect to a Hydrabase instance via WebSocket."""
@ -2552,6 +2633,10 @@ def hydrabase_connect():
timeout=10
)
_hydrabase_ws = ws
# Save credentials for auto-reconnect
config_manager.set('hydrabase.url', url)
config_manager.set('hydrabase.api_key', api_key)
config_manager.set('hydrabase.auto_connect', True)
print(f"🧪 [Hydrabase] Connected to {url}")
return jsonify({"success": True, "message": "Connected"})
except Exception as e:
@ -2560,8 +2645,8 @@ def hydrabase_connect():
@app.route('/api/hydrabase/disconnect', methods=['POST'])
def hydrabase_disconnect():
"""Disconnect from Hydrabase."""
global _hydrabase_ws
"""Disconnect from Hydrabase and disable dev mode."""
global _hydrabase_ws, dev_mode_enabled
with _hydrabase_lock:
if _hydrabase_ws:
try:
@ -2569,14 +2654,41 @@ def hydrabase_disconnect():
except:
pass
_hydrabase_ws = None
print("🧪 [Hydrabase] Disconnected")
config_manager.set('hydrabase.auto_connect', False)
dev_mode_enabled = False
print("🧪 [Hydrabase] Disconnected — dev mode disabled")
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})
try:
connected = _hydrabase_ws is not None and _hydrabase_ws.connected
except Exception:
connected = False
hydra_config = config_manager.get_hydrabase_config()
peer_count = None
try:
if hydrabase_client and hydrabase_client.last_peer_count is not None:
peer_count = hydrabase_client.last_peer_count
except NameError:
pass
return jsonify({
"connected": connected,
"saved_url": hydra_config.get('url', ''),
"saved_api_key": hydra_config.get('api_key', ''),
"auto_connect": hydra_config.get('auto_connect', False),
"peer_count": peer_count
})
@app.route('/api/hydrabase/comparisons')
def hydrabase_comparisons():
"""Get recent comparison results (Hydrabase vs Spotify vs iTunes)."""
if not dev_mode_enabled:
return jsonify({"success": False, "error": "Dev mode not active"}), 403
with _comparison_lock:
items = list(reversed(_hydrabase_comparisons.values()))
return jsonify({"success": True, "comparisons": items})
@app.route('/api/hydrabase/send', methods=['POST'])
def hydrabase_send():
@ -3967,18 +4079,11 @@ 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
# Search local database for artists (always)
database = get_database()
db_artists_objs = database.search_artists(query, limit=5)
# Convert database artists to dictionaries
db_artists = []
for artist in db_artists_objs:
image_url = None
@ -3992,60 +4097,112 @@ def enhanced_search():
})
logger.debug(f"DB Artist: {artist.name}, thumb_url: {artist.thumb_url if hasattr(artist, 'thumb_url') else None}, fixed_url: {image_url}")
# Search Spotify for artists, albums, tracks
spotify_artists = []
spotify_albums = []
spotify_tracks = []
metadata_source = "spotify"
if spotify_client and spotify_client.is_authenticated():
# Search for artists
artist_objs = spotify_client.search_artists(query, limit=10)
for artist in artist_objs:
spotify_artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url
})
if _is_hydrabase_active():
# ── Hydrabase is primary metadata source ──
metadata_source = "hydrabase"
try:
artist_objs = hydrabase_client.search_artists(query, limit=10)
for artist in artist_objs:
spotify_artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url
})
# Search for albums
album_objs = spotify_client.search_albums(query, limit=10)
for album in album_objs:
# Album has 'artists' (list), convert to string
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
album_objs = hydrabase_client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
spotify_albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type
})
spotify_albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type
})
track_objs = hydrabase_client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
spotify_tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date
})
# Search for tracks
track_objs = spotify_client.search_tracks(query, limit=10)
for track in track_objs:
# Track has 'artists' (list), convert to string
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
logger.info(f"Hydrabase search results: {len(spotify_artists)} artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
spotify_tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date
})
# Fire off background comparison
_run_background_comparison(query)
logger.info(f"Enhanced search results: {len(db_artists)} DB artists, {len(spotify_artists)} Spotify artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
except Exception as e:
logger.error(f"Hydrabase search failed, falling back to Spotify/iTunes: {e}")
metadata_source = "spotify"
spotify_artists = []
spotify_albums = []
spotify_tracks = []
if metadata_source != "hydrabase":
# ── Standard Spotify/iTunes path ──
# Mirror to Hydrabase worker (fire-and-forget)
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
hydrabase_worker.enqueue(query, 'album')
hydrabase_worker.enqueue(query, 'artist')
if spotify_client and spotify_client.is_authenticated():
artist_objs = spotify_client.search_artists(query, limit=10)
for artist in artist_objs:
spotify_artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url
})
album_objs = spotify_client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
spotify_albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type
})
track_objs = spotify_client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
spotify_tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date
})
logger.info(f"Enhanced search results ({metadata_source}): {len(db_artists)} DB artists, {len(spotify_artists)} artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
return jsonify({
"db_artists": db_artists,
"spotify_artists": spotify_artists,
"spotify_albums": spotify_albums,
"spotify_tracks": spotify_tracks
"spotify_tracks": spotify_tracks,
"metadata_source": metadata_source
})
except Exception as e:
@ -6829,13 +6986,18 @@ def search_match():
if not query:
return jsonify({"results": []})
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
use_hydrabase = _is_hydrabase_active()
# Mirror to Hydrabase P2P network (fire-and-forget when not primary)
if not use_hydrabase and 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)
if use_hydrabase:
artist_matches = hydrabase_client.search_artists(query, limit=8)
else:
artist_matches = spotify_client.search_artists(query, limit=8)
results = []
for artist in artist_matches:
@ -17065,8 +17227,10 @@ def get_spotify_track(track_id):
@app.route('/api/spotify/search', methods=['GET'])
def search_spotify():
"""Generic Spotify search endpoint - supports tracks, albums, artists"""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
use_hydrabase = _is_hydrabase_active()
if not use_hydrabase:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
query = request.args.get('q', '').strip()
@ -17076,15 +17240,14 @@ 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)
if use_hydrabase:
tracks = hydrabase_client.search_tracks(query, limit=limit)
else:
# Mirror to Hydrabase P2P network
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, search_type)
tracks = spotify_client.search_tracks(query, limit=limit)
# Search using spotify_client
tracks = spotify_client.search_tracks(query, limit=limit)
# Convert tracks to Spotify Web API format
# Note: t.artists and t.album are already dicts/lists in the right format
tracks_items = [{
'id': t.id,
'name': t.name,
@ -17103,8 +17266,10 @@ def search_spotify():
@app.route('/api/spotify/search_tracks', methods=['GET'])
def search_spotify_tracks():
"""Search for tracks on Spotify - used by discovery fix modal"""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
use_hydrabase = _is_hydrabase_active()
if not use_hydrabase:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
query = request.args.get('query', '').strip()
@ -17113,14 +17278,13 @@ 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')
if use_hydrabase:
tracks = hydrabase_client.search_tracks(query, limit=limit)
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
tracks = spotify_client.search_tracks(query, limit=limit)
# Search using spotify_client
tracks = spotify_client.search_tracks(query, limit=limit)
# Convert tracks to dict format
tracks_dict = [{
'id': t.id,
'name': t.name,
@ -17140,31 +17304,32 @@ def search_spotify_tracks():
def search_itunes_tracks():
"""Search for tracks on iTunes - used by discovery fix modal when iTunes is the source"""
try:
from core.itunes_client import iTunesClient
query = request.args.get('query', '').strip()
limit = int(request.args.get('limit', 20))
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')
use_hydrabase = _is_hydrabase_active()
if use_hydrabase:
tracks = hydrabase_client.search_tracks(query, limit=limit)
source = 'hydrabase'
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
tracks = itunes_client.search_tracks(query, limit=limit)
source = 'itunes'
# Search using iTunes client
itunes_client = iTunesClient()
tracks = itunes_client.search_tracks(query, limit=limit)
# Convert tracks to dict format matching Spotify structure for frontend compatibility
tracks_dict = [{
'id': t.id,
'name': t.name,
'artists': t.artists, # Already a list
'artists': t.artists,
'album': t.album,
'duration_ms': t.duration_ms,
'image_url': t.image_url,
'source': 'itunes'
'source': source
} for t in tracks]
return jsonify({'tracks': tracks_dict})
@ -22510,21 +22675,27 @@ 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)
artists = []
if results and 'artists' in results and 'items' in results['artists']:
for artist in results['artists']['items']:
if _is_hydrabase_active():
artist_objs = hydrabase_client.search_artists(query, limit=10)
for artist in artist_objs:
artists.append({
'id': artist['id'],
'name': artist['name'],
'image_url': artist['images'][0]['url'] if artist.get('images') else None
'id': artist.id,
'name': artist.name,
'image_url': artist.image_url
})
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'artist')
results = spotify_client.sp.search(q=query, type='artist', limit=10)
if results and 'artists' in results and 'items' in results['artists']:
for artist in results['artists']['items']:
artists.append({
'id': artist['id'],
'name': artist['name'],
'image_url': artist['images'][0]['url'] if artist.get('images') else None
})
return jsonify({
"success": True,
@ -27112,17 +27283,36 @@ def itunes_enrichment_resume():
# HYDRABASE P2P MIRROR WORKER
# ================================================================================================
# --- Hydrabase Worker Initialization ---
# --- Hydrabase Worker & Client Initialization ---
hydrabase_worker = None
hydrabase_client = 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")
hydrabase_client = HydrabaseClient(get_ws_and_lock=_get_hydrabase_ws_and_lock)
print("✅ Hydrabase P2P mirror worker and metadata client initialized")
except Exception as e:
print(f"⚠️ Hydrabase worker initialization failed: {e}")
print(f"⚠️ Hydrabase initialization failed: {e}")
hydrabase_worker = None
hydrabase_client = None
# --- Hydrabase Auto-Reconnect ---
try:
_hydra_cfg = config_manager.get_hydrabase_config()
if _hydra_cfg.get('auto_connect') and _hydra_cfg.get('url') and _hydra_cfg.get('api_key'):
import websocket as _ws_mod
_auto_ws = _ws_mod.create_connection(
_hydra_cfg['url'],
header={"x-api-key": _hydra_cfg['api_key']},
timeout=10
)
_hydrabase_ws = _auto_ws
dev_mode_enabled = True
print(f"✅ Hydrabase auto-connected to {_hydra_cfg['url']}")
except Exception as e:
print(f"⚠️ Hydrabase auto-reconnect failed: {e}")
# --- Hydrabase Worker API Endpoints ---
@ -27425,12 +27615,14 @@ 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)
if _is_hydrabase_active():
albums = hydrabase_client.search_albums(query, limit=limit)
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'album')
albums = spotify_client.search_albums(query, limit=limit)
results = []
for a in albums:

View file

@ -3556,6 +3556,26 @@
<div class="hydrabase-response-area" id="hydra-response">API responses will appear here</div>
</div>
</div>
<!-- Network Stats -->
<div class="hydrabase-card" style="margin-top: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Network</h3>
<span id="hydra-peer-count" style="color: #888; font-size: 13px;">Peers: --</span>
</div>
</div>
<!-- Source Comparisons -->
<div class="hydrabase-card" style="margin-top: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h3 style="margin: 0;">Source Comparisons</h3>
<button class="test-button" onclick="loadHydrabaseComparisons()">Refresh</button>
</div>
<p style="color: #888; font-size: 12px; margin: 0 0 8px 0;">When Hydrabase is the active metadata source, searches are compared against Spotify and iTunes in the background.</p>
<div id="hydra-comparisons-container">
<p style="color: #666; font-size: 13px;">No comparisons yet. Search with Hydrabase active to generate comparisons.</p>
</div>
</div>
</div>
</div>

View file

@ -605,7 +605,7 @@ async function loadPageData(pageId) {
await loadQualityProfile();
break;
case 'hydrabase':
// Check connection status on page load
// Check connection status and pre-fill saved credentials
try {
const hsResp = await fetch('/api/hydrabase/status');
const hsData = await hsResp.json();
@ -613,7 +613,20 @@ async function loadPageData(pageId) {
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';
// Pre-fill saved credentials
if (hsData.saved_url) {
document.getElementById('hydra-ws-url').value = hsData.saved_url;
}
if (hsData.saved_api_key) {
document.getElementById('hydra-api-key').value = hsData.saved_api_key;
}
// Update peer count
if (hsData.peer_count !== null && hsData.peer_count !== undefined) {
document.getElementById('hydra-peer-count').textContent = `Peers: ${hsData.peer_count}`;
}
} catch (e) {}
// Load comparisons
loadHydrabaseComparisons();
break;
}
} catch (error) {
@ -2316,7 +2329,56 @@ async function hydrabaseDisconnect() {
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');
// Dev mode is disabled on disconnect — hide Hydrabase nav and update settings status
document.getElementById('hydrabase-nav').style.display = 'none';
document.getElementById('hydrabase-button-container').style.display = 'none';
const devStatus = document.getElementById('dev-mode-status');
if (devStatus) {
devStatus.textContent = 'Inactive';
devStatus.style.color = '#888';
}
showToast('Disconnected — dev mode disabled', 'success');
navigateToPage('settings');
}
async function loadHydrabaseComparisons() {
const container = document.getElementById('hydra-comparisons-container');
if (!container) return;
try {
const response = await fetch('/api/hydrabase/comparisons');
const data = await response.json();
if (!data.success || !data.comparisons?.length) {
container.innerHTML = '<p style="color: #666; font-size: 13px;">No comparisons yet. Search with Hydrabase active to generate comparisons.</p>';
return;
}
let html = '';
for (const comp of data.comparisons) {
const time = new Date(comp.timestamp * 1000).toLocaleTimeString();
html += `<div style="background: rgba(30, 30, 30, 0.6); border-radius: 8px; padding: 10px; margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<strong style="color: #fff;">"${comp.query}"</strong>
<span style="color: #666; font-size: 11px;">${time}</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; font-size: 12px;">
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(99, 102, 241, 0.15); border: 1px solid rgba(99, 102, 241, 0.3);">
<div style="color: rgba(139, 92, 246, 1); font-weight: 600; margin-bottom: 2px;">Hydrabase</div>
<div style="color: #aaa;">${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al</div>
</div>
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(29, 185, 84, 0.15); border: 1px solid rgba(29, 185, 84, 0.3);">
<div style="color: #1ed760; font-weight: 600; margin-bottom: 2px;">Spotify</div>
<div style="color: #aaa;">${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al</div>
</div>
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(251, 93, 93, 0.15); border: 1px solid rgba(251, 93, 93, 0.3);">
<div style="color: #fb5d5d; font-weight: 600; margin-bottom: 2px;">iTunes</div>
<div style="color: #aaa;">${comp.itunes?.tracks || 0}T / ${comp.itunes?.artists || 0}A / ${comp.itunes?.albums || 0}Al</div>
</div>
</div>
</div>`;
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<p style="color: #f44336; font-size: 13px;">Failed to load comparisons.</p>';
}
}
async function hydrabaseSendRaw(textareaId) {
@ -3013,6 +3075,12 @@ function initializeSearchModeToggle() {
}
function renderDropdownResults(data) {
// Determine source badge
const metadataSource = data.metadata_source || 'spotify';
const sourceBadge = metadataSource === 'hydrabase'
? { text: 'Hydrabase', class: 'enh-badge-hydrabase' }
: { text: 'Spotify', class: 'enh-badge-spotify' };
// Render DB Artists
renderCompactSection(
'enh-db-artists-section',
@ -3033,7 +3101,7 @@ function initializeSearchModeToggle() {
})
);
// Render Spotify Artists
// Render Artists (source-aware badge)
renderCompactSection(
'enh-spotify-artists-section',
'enh-spotify-artists-list',
@ -3044,7 +3112,7 @@ function initializeSearchModeToggle() {
placeholder: '🎤',
name: artist.name,
meta: 'Artist',
badge: { text: 'Spotify', class: 'enh-badge-spotify' },
badge: sourceBadge,
onClick: async () => {
console.log(`🎵 Opening Spotify artist detail: ${artist.name} (ID: ${artist.id})`);
hideDropdown();

View file

@ -21497,6 +21497,19 @@ body {
display: none;
}
/* Hydrabase badge (dev mode) */
.enh-badge-hydrabase {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));
border: 1px solid rgba(99, 102, 241, 0.5);
color: rgba(139, 92, 246, 1);
}
.enh-sr-artist-badge-hydrabase {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));
border: 1px solid rgba(99, 102, 241, 0.5);
color: rgba(139, 92, 246, 1);
}
/* ========================================= */
/* ALBUM CARDS - LARGE Spotify Browse Style */