Fix Hydrabase endpoint gaps, M3U default, and soul_id mapping

Filled all missing Hydrabase fallthrough gates across 6 endpoints (artist album tracks, iTunes album, discover album, Spotify track, both similar artists), added     track_number/disc_number to Track dataclass, fixed get_album_tracks to send soul_id instead of text query, mapped soul_id field from Hydrabase responses across all search methods, updated 7 frontend call sites to pass album name/artist params, and fixed M3U export defaulting to enabled when users never turned it on.
This commit is contained in:
Broque Thomas 2026-02-28 18:25:55 -08:00
parent aa176fdf9a
commit 49c769ff5c
4 changed files with 244 additions and 87 deletions

View file

@ -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}")

View file

@ -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':

View file

@ -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/<track_id>', 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

View file

@ -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');
}