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:
parent
1c34967fd3
commit
1283041836
6 changed files with 590 additions and 111 deletions
|
|
@ -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
178
core/hydrabase_client.py
Normal 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)
|
||||
406
web_server.py
406
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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue