diff --git a/core/hydrabase_client.py b/core/hydrabase_client.py index 2578ab09..ae0bddad 100644 --- a/core/hydrabase_client.py +++ b/core/hydrabase_client.py @@ -160,7 +160,7 @@ class HydrabaseClient: for item in results[:limit]: try: tracks.append(Track( - id=str(item.get('id', '')), + id=str(item.get('soul_id', item.get('id', ''))), name=item.get('name', ''), artists=item.get('artists', []), album=item.get('album', ''), @@ -186,7 +186,7 @@ class HydrabaseClient: for item in results[:limit]: try: artists.append(Artist( - id=str(item.get('id', '')), + id=str(item.get('soul_id', item.get('id', ''))), name=item.get('name', ''), popularity=item.get('popularity', 0), genres=item.get('genres', []), @@ -209,7 +209,7 @@ class HydrabaseClient: for item in results[:limit]: try: albums.append(Album( - id=str(item.get('id', '')), + id=str(item.get('soul_id', item.get('id', ''))), name=item.get('name', ''), artists=item.get('artists', []), release_date=self._normalize_release_date(item.get('release_date', '')), @@ -234,7 +234,7 @@ class HydrabaseClient: for item in results[:limit]: try: albums.append(Album( - id=str(item.get('id', '')), + id=str(item.get('soul_id', item.get('id', ''))), name=item.get('name', ''), artists=item.get('artists', []), release_date=self._normalize_release_date(item.get('release_date', '')), @@ -247,9 +247,9 @@ class HydrabaseClient: 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) + def get_album_tracks(self, album_id: str, limit: int = 50) -> List[Track]: + """Fetch tracks for an album from Hydrabase by soul_id.""" + results = self._send_and_recv('album_tracks', album_id) if not results: return [] @@ -257,7 +257,7 @@ class HydrabaseClient: for item in results[:limit]: try: tracks.append(Track( - id=str(item.get('id', '')), + id=str(item.get('soul_id', item.get('id', ''))), name=item.get('name', ''), artists=item.get('artists', []), album=item.get('album', ''), @@ -266,7 +266,9 @@ class HydrabaseClient: 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', '')) + release_date=self._normalize_release_date(item.get('release_date', '')), + track_number=item.get('track_number'), + disc_number=item.get('disc_number'), )) except Exception as e: logger.debug(f"Skipping malformed Hydrabase album track: {e}") diff --git a/core/itunes_client.py b/core/itunes_client.py index 53e57e52..a30f230a 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -69,6 +69,8 @@ class Track: external_urls: Optional[Dict[str, str]] = None image_url: Optional[str] = None release_date: Optional[str] = None + track_number: Optional[int] = None + disc_number: Optional[int] = None @classmethod def from_itunes_track(cls, track_data: Dict[str, Any], clean_artist_name: Optional[str] = None) -> 'Track': diff --git a/web_server.py b/web_server.py index dfe11c65..2a6bf953 100644 --- a/web_server.py +++ b/web_server.py @@ -2289,7 +2289,7 @@ def save_playlist_m3u(): force = data.get('force', False) # Check if M3U export is enabled (unless force=True from manual Export button) - if not force and not config_manager.get('m3u_export.enabled', True): + if not force and not config_manager.get('m3u_export.enabled', False): return jsonify({"status": "success", "message": "M3U export disabled in settings", "skipped": True}) if not m3u_content: @@ -5850,32 +5850,41 @@ def get_similar_artists_stream(artist_name): print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap") - # Initialize Spotify client - if not spotify_client or not spotify_client.is_authenticated(): - yield f"data: {json.dumps({'error': 'Spotify not authenticated'})}\n\n" - return + # Determine metadata source + use_hydrabase = _is_hydrabase_active() - # Get the searched artist's Spotify ID to exclude them + if not use_hydrabase: + if not spotify_client or not spotify_client.is_authenticated(): + yield f"data: {json.dumps({'error': 'Spotify not authenticated'})}\n\n" + return + + # Get the searched artist's ID to exclude them searched_artist_id = None try: - searched_results = spotify_client.search_artists(artist_name, limit=1) + if use_hydrabase: + searched_results = hydrabase_client.search_artists(artist_name, limit=1) + else: + searched_results = spotify_client.search_artists(artist_name, limit=1) if searched_results and len(searched_results) > 0: searched_artist_id = searched_results[0].id - print(f"🎯 Searched artist Spotify ID: {searched_artist_id}") + print(f"🎯 Searched artist ID: {searched_artist_id}") except Exception as e: print(f"⚠️ Could not get searched artist ID: {e}") - # Match each artist to Spotify one by one and stream results + # Match each artist one by one and stream results max_artists = 20 matched_count = 0 seen_artist_ids = set() # Track seen artist IDs to prevent duplicates for artist_name_to_match in similar_artist_names[:max_artists]: try: - print(f"🔍 Matching to Spotify: {artist_name_to_match}") + print(f"🔍 Matching: {artist_name_to_match}") - # Search Spotify for the artist - results = spotify_client.search_artists(artist_name_to_match, limit=1) + # Search for the artist via active metadata source + if use_hydrabase: + results = hydrabase_client.search_artists(artist_name_to_match, limit=1) + else: + results = spotify_client.search_artists(artist_name_to_match, limit=1) if results and len(results) > 0: spotify_artist = results[0] @@ -5988,34 +5997,43 @@ def get_similar_artists(artist_name): print(f"📦 Found {len(similar_artist_names)} similar artists from MusicMap") - # Initialize Spotify client - if not spotify_client or not spotify_client.is_authenticated(): - return jsonify({ - "success": False, - "error": "Spotify not authenticated" - }), 401 + # Determine metadata source + use_hydrabase = _is_hydrabase_active() - # Get the searched artist's Spotify ID to exclude them + if not use_hydrabase: + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({ + "success": False, + "error": "Spotify not authenticated" + }), 401 + + # Get the searched artist's ID to exclude them searched_artist_id = None try: - searched_results = spotify_client.search_artists(artist_name, limit=1) + if use_hydrabase: + searched_results = hydrabase_client.search_artists(artist_name, limit=1) + else: + searched_results = spotify_client.search_artists(artist_name, limit=1) if searched_results and len(searched_results) > 0: searched_artist_id = searched_results[0].id - print(f"🎯 Searched artist Spotify ID: {searched_artist_id}") + print(f"🎯 Searched artist ID: {searched_artist_id}") except Exception as e: print(f"⚠️ Could not get searched artist ID: {e}") - # Match each artist to Spotify (limit to first 20 for performance) + # Match each artist (limit to first 20 for performance) matched_artists = [] max_artists = 20 seen_artist_ids = set() # Track seen artist IDs to prevent duplicates for artist_name_to_match in similar_artist_names[:max_artists]: try: - print(f"🔍 Matching to Spotify: {artist_name_to_match}") + print(f"🔍 Matching: {artist_name_to_match}") - # Search Spotify for the artist - results = spotify_client.search_artists(artist_name_to_match, limit=1) + # Search for the artist via active metadata source + if use_hydrabase: + results = hydrabase_client.search_artists(artist_name_to_match, limit=1) + else: + results = spotify_client.search_artists(artist_name_to_match, limit=1) if results and len(results) > 0: spotify_artist = results[0] @@ -6314,9 +6332,50 @@ def _resolve_db_album_id(album_id, artist_id=None): def get_artist_album_tracks(artist_id, album_id): """Get tracks for specific album formatted for download missing tracks modal""" try: + # Try Hydrabase first when active and album name provided + if _is_hydrabase_active(): + album_name = request.args.get('name', '') + album_artist = request.args.get('artist', '') + try: + hydra_tracks = hydrabase_client.get_album_tracks(album_id, limit=50) + if hydra_tracks: + album_info = { + 'id': album_id, + 'name': album_name or hydra_tracks[0].album or '', + 'image_url': None, + 'images': [], + 'release_date': '', + 'album_type': 'album', + 'total_tracks': len(hydra_tracks) + } + formatted_tracks = [] + for t in hydra_tracks: + artist_list = t.artists if isinstance(t.artists, list) else [t.artists] if t.artists else [] + formatted_tracks.append({ + 'id': t.id, + 'name': t.name, + 'artists': [a if isinstance(a, str) else a for a in artist_list], + 'duration_ms': t.duration_ms, + 'track_number': t.track_number or 0, + 'disc_number': t.disc_number or 1, + 'explicit': False, + 'preview_url': t.preview_url, + 'external_urls': t.external_urls or {}, + 'uri': '', + 'album': album_info + }) + print(f"✅ Hydrabase returned {len(formatted_tracks)} tracks for album: {album_info['name']}") + return jsonify({ + 'success': True, + 'album': album_info, + 'tracks': formatted_tracks + }) + except Exception as e: + logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to Spotify: {e}") + if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated"}), 401 - + print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id}") # Get album information first @@ -17710,39 +17769,37 @@ 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 + # Try Hydrabase first when active — look up by album soul_id 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 + try: + hydra_tracks = hydrabase_client.get_album_tracks(album_id, 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': t.track_number or 0, + 'disc_number': t.disc_number 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': '' }) - except Exception as e: - logger.warning(f"Hydrabase album_tracks failed for '{album_name}', falling back to Spotify: {e}") + return jsonify({ + 'id': album_id, + 'name': album_name or hydra_tracks[0].album or '', + '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_id}', falling back to Spotify: {e}") if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated."}), 401 @@ -17780,6 +17837,30 @@ def get_album_tracks(album_id): @app.route('/api/spotify/track/', methods=['GET']) def get_spotify_track(track_id): """Fetches full track details including album data for a specific track.""" + # Try Hydrabase first when active and track name provided + if _is_hydrabase_active(): + track_name = request.args.get('name', '') + track_artist = request.args.get('artist', '') + if track_name: + try: + query = f"{track_artist} {track_name}".strip() if track_artist else track_name + hydra_tracks = hydrabase_client.search_tracks(query, limit=1) + if hydra_tracks: + t = hydra_tracks[0] + artist_list = t.artists if isinstance(t.artists, list) else [t.artists] if t.artists else [] + return jsonify({ + 'id': t.id, + 'name': t.name, + 'artists': [{'name': a} if isinstance(a, str) else a for a in artist_list], + 'album': {'name': t.album, 'images': [{'url': t.image_url}] if t.image_url else []}, + 'duration_ms': t.duration_ms, + 'preview_url': t.preview_url, + 'external_urls': t.external_urls or {}, + 'popularity': t.popularity, + }) + except Exception as e: + logger.warning(f"Hydrabase track lookup failed for '{track_name}', falling back to Spotify: {e}") + if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated."}), 401 try: @@ -17911,6 +17992,39 @@ def search_itunes_tracks(): def get_itunes_album_tracks(album_id): """Fetches full track details for a specific iTunes album.""" try: + # Try Hydrabase first when active — look up by album soul_id + if _is_hydrabase_active(): + album_name = request.args.get('name', '') + album_artist = request.args.get('artist', '') + try: + hydra_tracks = hydrabase_client.get_album_tracks(album_id, 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': t.track_number or 0, + 'disc_number': t.disc_number 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 or hydra_tracks[0].album or '', + 'artists': [{'name': album_artist}] if album_artist else [], + 'release_date': '', + 'total_tracks': len(track_items), + 'album_type': 'album', + 'images': [], + 'tracks': track_items, + 'source': 'hydrabase' + }) + except Exception as e: + logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to iTunes: {e}") + from core.itunes_client import iTunesClient itunes_client = iTunesClient() @@ -17946,9 +18060,42 @@ def get_itunes_album_tracks(album_id): def get_discover_album(source, album_id): """ Source-agnostic album endpoint for discover page. - Fetches album from the appropriate source (spotify or itunes). + Fetches album from the appropriate source (spotify, itunes, or hydrabase when active). """ try: + # Try Hydrabase first when active — look up by album soul_id + if _is_hydrabase_active(): + album_name = request.args.get('name', '') + album_artist = request.args.get('artist', '') + try: + hydra_tracks = hydrabase_client.get_album_tracks(album_id, 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': t.track_number or 0, + 'disc_number': t.disc_number 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 or hydra_tracks[0].album or '', + 'artists': [{'name': album_artist}] if album_artist else [], + 'release_date': '', + 'total_tracks': len(track_items), + 'album_type': 'album', + 'images': [], + 'tracks': track_items, + 'source': 'hydrabase' + }) + except Exception as e: + logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to {source}: {e}") + if source == 'spotify': if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated."}), 401 @@ -28231,19 +28378,18 @@ def import_album_match(): spotify_tracks = None album_info = None - # Try Hydrabase first when active and album_name available - if _is_hydrabase_active() and album_name: + # Try Hydrabase first when active — look up by album soul_id + if _is_hydrabase_active(): try: - query = f"{album_artist} {album_name}".strip() if album_artist else album_name - hydra_tracks = hydrabase_client.get_album_tracks(query, limit=50) + hydra_tracks = hydrabase_client.get_album_tracks(album_id, 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, + 'track_number': t.track_number or 0, + 'disc_number': t.disc_number or 1, 'duration_ms': t.duration_ms, 'id': t.id, 'artists': [{'name': a} if isinstance(a, str) else a for a in artist_list], @@ -28251,7 +28397,7 @@ def import_album_match(): }) album_info = { 'id': album_id, - 'name': album_name, + 'name': album_name or hydra_tracks[0].album or '', 'artist': album_artist, 'artists': [album_artist] if album_artist else [], 'release_date': '', @@ -28259,7 +28405,7 @@ def import_album_match(): 'image_url': None, 'genres': [] } - logger.info(f"Hydrabase album_tracks returned {len(spotify_tracks)} tracks for '{query}'") + logger.info(f"Hydrabase album_tracks returned {len(spotify_tracks)} tracks for album_id '{album_id}'") except Exception as e: logger.warning(f"Hydrabase album_tracks failed, falling back to Spotify: {e}") spotify_tracks = None diff --git a/webui/static/script.js b/webui/static/script.js index 16b8def8..5b772739 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -4307,11 +4307,12 @@ async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, bat const { item, type } = downloadData; if (type === 'album') { - // For albums, fetch tracks from Spotify API + // For albums, fetch tracks (pass name/artist for Hydrabase support) console.log(`💧 Album download - fetching album ${item.id}...`); try { - const response = await fetch(`/api/spotify/album/${item.id}`); + const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); + const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`); if (!response.ok) { console.error(`❌ Failed to fetch album: ${response.status}`); return; @@ -13307,10 +13308,11 @@ async function confirmMatch() { console.log('🎵 Fetching Spotify tracklist for album:', currentMatchingData.selectedAlbum.name); try { - // Fetch Spotify album tracks + // Fetch album tracks (pass name/artist for Hydrabase support) const artistId = currentMatchingData.selectedArtist.id; const albumId = currentMatchingData.selectedAlbum.id; - const tracksResponse = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`); + const _aat3 = new URLSearchParams({ name: currentMatchingData.selectedAlbum.name || '', artist: currentMatchingData.selectedArtist.name || '' }); + const tracksResponse = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks?${_aat3}`); if (!tracksResponse.ok) { throw new Error(`Failed to fetch Spotify tracks: ${tracksResponse.status}`); @@ -23289,8 +23291,9 @@ async function createArtistAlbumVirtualPlaylist(album, albumType) { try { // Loading overlay already shown by handleArtistAlbumClick - // Fetch album tracks from backend - const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks`); + // Fetch album tracks from backend (pass name/artist for Hydrabase support) + const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); + const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks?${_aat1}`); if (!response.ok) { if (response.status === 401) { @@ -24148,11 +24151,12 @@ async function reopenDownloadModal(virtualPlaylistId) { // For albums, we need to fetch the tracks console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`); - // Fetch album tracks from Spotify API + // Fetch album tracks (pass name/artist for Hydrabase support) showLoadingOverlay(`Loading ${item.name}...`); try { - const response = await fetch(`/api/spotify/album/${item.id}`); + const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); + const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`); if (!response.ok) { throw new Error('Failed to fetch album tracks'); } @@ -28127,8 +28131,9 @@ function createReleaseCard(release) { return; } - // Load tracks for the album - const response = await fetch(`/api/artist/${currentArtist.id}/album/${albumData.id}/tracks`); + // Load tracks for the album (pass name/artist for Hydrabase support) + const _aat2 = new URLSearchParams({ name: albumData.name || '', artist: currentArtist.name || '' }); + const response = await fetch(`/api/artist/${currentArtist.id}/album/${albumData.id}/tracks?${_aat2}`); if (!response.ok) { throw new Error(`Failed to load album tracks: ${response.status}`); } @@ -35380,8 +35385,9 @@ async function openDownloadModalForSeasonalAlbum(albumIndex) { throw new Error('No album ID available'); } - // Fetch album tracks from appropriate source via backend - const response = await fetch(`/api/discover/album/${source}/${albumId}`); + // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) + const _dap1 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap1}`); if (!response.ok) { throw new Error('Failed to fetch album tracks'); } @@ -36291,8 +36297,9 @@ async function openDownloadModalForRecentAlbum(albumIndex) { throw new Error(`No ${source} album ID available`); } - // Fetch album tracks from appropriate source via backend - const response = await fetch(`/api/discover/album/${source}/${albumId}`); + // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) + const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`); if (!response.ok) { throw new Error('Failed to fetch album tracks'); }