soulsync/core/hydrabase_client.py
Broque Thomas f90d6567f7 Hydrabase: improve parsing add discography/tracks
Improve Hydrabase response handling and add discography/album track helpers. core/hydrabase_client.py: extract peer counts from stats.connectedPeers, handle new "response" key and stats-only or unexpected response shapes (return empty instead of wrapping), and add search_discography() and get_album_tracks() to map Hydrabase results into Album/Track objects. web_server.py: avoid redundant Hydrabase round-trips by passing precomputed hydrabase_counts into the background comparison worker; prefer Hydrabase for artist discography and album track import when active (with Spotify fallback); and route album-context searches to Hydrabase when configured. These changes reduce duplicate network calls and improve robustness against varied Hydrabase payloads.
2026-02-24 18:42:48 -08:00

242 lines
9.2 KiB
Python

"""
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)
# Extract stats if present (can appear alongside results)
if isinstance(data, dict) and 'stats' in data:
stats = data['stats']
if isinstance(stats, dict) and 'connectedPeers' in stats:
import time
self.last_peer_count = stats['connectedPeers']
self.last_peer_count_time = time.time()
# Handle various response shapes
if isinstance(data, list):
return data
if isinstance(data, dict):
# Hydrabase returns results under "response" key
if 'response' in data:
resp = data['response']
return resp if isinstance(resp, list) else [resp]
if 'results' in data:
return data['results']
if 'data' in data:
result = data['data']
return result if isinstance(result, list) else [result]
# Stats-only or empty response — no search results
if 'stats' in data:
logger.debug(f"Hydrabase stats-only response for ({request_type}, '{query}')")
return []
# Unknown shape — return empty rather than wrapping garbage
logger.debug(f"Hydrabase unexpected response shape for ({request_type}, '{query}'): {list(data.keys()) if isinstance(data, dict) else type(data).__name__}")
return []
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
# ==================== Discography Methods ====================
def search_discography(self, artist_name: str, limit: int = 50) -> List[Album]:
"""Fetch an artist's discography (albums + singles) from Hydrabase."""
results = self._send_and_recv('discography', artist_name)
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 discography album: {e}")
return albums
def get_album_tracks(self, album_query: str, limit: int = 50) -> List[Track]:
"""Fetch tracks for an album from Hydrabase using the album_tracks type."""
results = self._send_and_recv('album_tracks', album_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 album track: {e}")
return tracks
# ==================== 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)