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.
This commit is contained in:
Broque Thomas 2026-02-24 18:42:48 -08:00
parent 44297a210b
commit f90d6567f7
3 changed files with 229 additions and 62 deletions

View file

@ -70,21 +70,35 @@ class HydrabaseClient:
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()
# 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) 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 []
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
@ -171,6 +185,56 @@ class HydrabaseClient:
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]:

View file

@ -2579,23 +2579,32 @@ def _is_hydrabase_active():
except NameError:
return False
def _run_background_comparison(query):
"""Run Spotify + iTunes searches in background and store for comparison."""
def _run_background_comparison(query, hydrabase_counts=None):
"""Run Spotify + iTunes searches in background and store for comparison.
Args:
query: Search query string.
hydrabase_counts: Optional pre-computed dict {'tracks': N, 'artists': N, 'albums': N}
from the primary search to avoid redundant Hydrabase round-trips.
"""
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
}
# Use pre-computed counts if available, otherwise fetch from Hydrabase
if hydrabase_counts is not None:
hydra_data = hydrabase_counts
else:
hydra_data = {'tracks': 0, 'artists': 0, 'albums': 0}
if _is_hydrabase_active():
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
@ -4178,8 +4187,12 @@ def enhanced_search():
logger.info(f"Hydrabase search results: {len(spotify_artists)} artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
# Fire off background comparison
_run_background_comparison(query)
# Fire off background comparison with pre-computed Hydrabase counts
_run_background_comparison(query, hydrabase_counts={
'tracks': len(track_objs),
'artists': len(artist_objs),
'albums': len(album_objs)
})
except Exception as e:
logger.error(f"Hydrabase search failed, falling back to Spotify/iTunes: {e}")
@ -5843,11 +5856,21 @@ def get_artist_discography(artist_id):
albums = []
active_source = None
# Try Hydrabase first when active
if _is_hydrabase_active() and artist_name:
try:
albums = hydrabase_client.search_discography(artist_name, limit=50)
if albums:
active_source = 'hydrabase'
print(f"📊 Got {len(albums)} albums from Hydrabase")
except Exception as e:
print(f"Hydrabase discography failed: {e}")
# Try to get albums from the appropriate source
# Check if the ID looks like Spotify (alphanumeric) or iTunes (numeric only)
is_numeric_id = artist_id.isdigit()
if spotify_available and not is_numeric_id:
if not albums and spotify_available and not is_numeric_id:
# Try Spotify first for alphanumeric IDs
try:
albums = spotify_client.get_artist_albums(artist_id, album_type='album,single')
@ -7082,20 +7105,24 @@ def search_match():
elif context == 'album':
# Search for albums by specific artist
artist_id = data.get('artist_id')
if not artist_id:
return jsonify({"error": "Artist ID required for album search"}), 400
# Get artist's albums and filter by query
artist_albums = spotify_client.get_artist_albums(artist_id)
if use_hydrabase:
# Hydrabase: search albums by query directly
album_matches = hydrabase_client.search_albums(query, limit=20)
else:
if not artist_id:
return jsonify({"error": "Artist ID required for album search"}), 400
# Get artist's albums and filter by query
album_matches = spotify_client.get_artist_albums(artist_id)
results = []
for album in artist_albums:
for album in album_matches:
# Calculate confidence based on query similarity
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(query),
matching_engine.normalize_string(album.name)
)
# Only include results with reasonable similarity
if confidence > 0.3:
results.append({
@ -7109,7 +7136,7 @@ def search_match():
},
"confidence": confidence
})
# Sort by confidence
results.sort(key=lambda x: x['confidence'], reverse=True)
return jsonify({"results": results[:8]})
@ -17284,6 +17311,42 @@ def get_playlist_tracks(playlist_id):
@app.route('/api/spotify/album/<album_id>', methods=['GET'])
def get_album_tracks(album_id):
"""Fetches full track details for a specific album."""
use_hydrabase = _is_hydrabase_active()
# Try Hydrabase first when active and album name provided
if use_hydrabase:
album_name = request.args.get('name', '')
album_artist = request.args.get('artist', '')
if album_name:
try:
query = f"{album_artist} {album_name}".strip() if album_artist else album_name
hydra_tracks = hydrabase_client.get_album_tracks(query, limit=50)
if hydra_tracks:
track_items = []
for t in hydra_tracks:
artist_list = t.artists if isinstance(t.artists, list) else [t.artists] if t.artists else []
track_items.append({
'name': t.name,
'track_number': getattr(t, 'track_number', 0) or 0,
'disc_number': getattr(t, 'disc_number', 1) or 1,
'duration_ms': t.duration_ms,
'id': t.id,
'artists': [{'name': a} if isinstance(a, str) else a for a in artist_list],
'uri': ''
})
return jsonify({
'id': album_id,
'name': album_name,
'artists': [{'name': album_artist}] if album_artist else [],
'release_date': '',
'total_tracks': len(track_items),
'album_type': 'album',
'images': [],
'tracks': track_items
})
except Exception as e:
logger.warning(f"Hydrabase album_tracks failed for '{album_name}', falling back to Spotify: {e}")
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
@ -27756,37 +27819,76 @@ def import_album_match():
try:
data = request.get_json()
album_id = data.get('album_id')
album_name = data.get('album_name', '')
album_artist = data.get('album_artist', '')
if not album_id:
return jsonify({'success': False, 'error': 'Missing album_id'}), 400
# Get album info and tracklist from Spotify
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({'success': False, 'error': 'Album not found'}), 404
spotify_tracks = None
album_info = None
tracks_data = spotify_client.get_album_tracks(album_id)
if not tracks_data or 'items' not in tracks_data:
return jsonify({'success': False, 'error': 'Could not get album tracks'}), 500
# Try Hydrabase first when active and album_name available
if _is_hydrabase_active() and album_name:
try:
query = f"{album_artist} {album_name}".strip() if album_artist else album_name
hydra_tracks = hydrabase_client.get_album_tracks(query, limit=50)
if hydra_tracks:
spotify_tracks = []
for t in hydra_tracks:
artist_list = t.artists if isinstance(t.artists, list) else [t.artists] if t.artists else []
spotify_tracks.append({
'name': t.name,
'track_number': getattr(t, 'track_number', 0) or 0,
'disc_number': getattr(t, 'disc_number', 1) or 1,
'duration_ms': t.duration_ms,
'id': t.id,
'artists': [{'name': a} if isinstance(a, str) else a for a in artist_list],
'uri': ''
})
album_info = {
'id': album_id,
'name': album_name,
'artist': album_artist,
'artists': [album_artist] if album_artist else [],
'release_date': '',
'total_tracks': len(spotify_tracks),
'image_url': None,
'genres': []
}
logger.info(f"Hydrabase album_tracks returned {len(spotify_tracks)} tracks for '{query}'")
except Exception as e:
logger.warning(f"Hydrabase album_tracks failed, falling back to Spotify: {e}")
spotify_tracks = None
spotify_tracks = tracks_data['items']
# Fall back to Spotify
if spotify_tracks is None:
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({'success': False, 'error': 'Album not found'}), 404
# Build album summary
album_artists = [a['name'] for a in album_data.get('artists', [])]
album_info = {
'id': album_id,
'name': album_data.get('name', 'Unknown Album'),
'artist': ', '.join(album_artists),
'artists': album_artists,
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', len(spotify_tracks)),
'image_url': (album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None),
'genres': album_data.get('genres', [])
}
tracks_data = spotify_client.get_album_tracks(album_id)
if not tracks_data or 'items' not in tracks_data:
return jsonify({'success': False, 'error': 'Could not get album tracks'}), 500
# Get artist info for context building later
if album_data.get('artists'):
primary_artist = album_data['artists'][0]
album_info['artist_id'] = primary_artist.get('id', '')
spotify_tracks = tracks_data['items']
# Build album summary
album_artists = [a['name'] for a in album_data.get('artists', [])]
album_info = {
'id': album_id,
'name': album_data.get('name', 'Unknown Album'),
'artist': ', '.join(album_artists),
'artists': album_artists,
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', len(spotify_tracks)),
'image_url': (album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None),
'genres': album_data.get('genres', [])
}
# Get artist info for context building later
if album_data.get('artists'):
primary_artist = album_data['artists'][0]
album_info['artist_id'] = primary_artist.get('id', '')
# Scan staging files
staging_path = _get_staging_path()

View file

@ -3364,8 +3364,9 @@ function initializeSearchModeToggle() {
showLoadingOverlay('Loading album...');
try {
// Fetch full album data with tracks from Spotify
const response = await fetch(`/api/spotify/album/${album.id}`);
// Fetch full album data with tracks (Hydrabase or Spotify)
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`);
if (!response.ok) {
if (response.status === 401) {