diff --git a/config/settings.py b/config/settings.py index 2d44f779..a8375819 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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', {}) diff --git a/core/hydrabase_client.py b/core/hydrabase_client.py new file mode 100644 index 00000000..259785ad --- /dev/null +++ b/core/hydrabase_client.py @@ -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) diff --git a/web_server.py b/web_server.py index 8977acba..1fb95437 100644 --- a/web_server.py +++ b/web_server.py @@ -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: diff --git a/webui/index.html b/webui/index.html index b06069fb..602567a5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3556,6 +3556,26 @@
API responses will appear here
+ + +
+
+

Network

+ Peers: -- +
+
+ + +
+
+

Source Comparisons

+ +
+

When Hydrabase is the active metadata source, searches are compared against Spotify and iTunes in the background.

+
+

No comparisons yet. Search with Hydrabase active to generate comparisons.

+
+
diff --git a/webui/static/script.js b/webui/static/script.js index f0e5cd98..e401ff88 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = '

No comparisons yet. Search with Hydrabase active to generate comparisons.

'; + return; + } + let html = ''; + for (const comp of data.comparisons) { + const time = new Date(comp.timestamp * 1000).toLocaleTimeString(); + html += `
+
+ "${comp.query}" + ${time} +
+
+
+
Hydrabase
+
${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al
+
+
+
Spotify
+
${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al
+
+
+
iTunes
+
${comp.itunes?.tracks || 0}T / ${comp.itunes?.artists || 0}A / ${comp.itunes?.albums || 0}Al
+
+
+
`; + } + container.innerHTML = html; + } catch (e) { + container.innerHTML = '

Failed to load comparisons.

'; + } } 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(); diff --git a/webui/static/style.css b/webui/static/style.css index b560f35f..3b82bbb0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 */