From f90d6567f7a091936d353c860ab60652a5387a83 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 24 Feb 2026 18:42:48 -0800 Subject: [PATCH] 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. --- core/hydrabase_client.py | 86 ++++++++++++++--- web_server.py | 200 +++++++++++++++++++++++++++++---------- webui/static/script.js | 5 +- 3 files changed, 229 insertions(+), 62 deletions(-) diff --git a/core/hydrabase_client.py b/core/hydrabase_client.py index 259785ad..86956f7b 100644 --- a/core/hydrabase_client.py +++ b/core/hydrabase_client.py @@ -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]: diff --git a/web_server.py b/web_server.py index c6dd80c5..2963d7af 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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() diff --git a/webui/static/script.js b/webui/static/script.js index e401ff88..6d5cb4fd 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) {