Fix manual discovery match producing download cards with no cover art
User reported: after clicking Fix on a Not-Found discovery track and
picking a replacement in the fix modal, the resulting download card had
no cover image, and the track seemed to still behave like a Wing-It
stub. Both suspicions were correct. Three compounding bugs:
1. /api/spotify/search_tracks returned only id/name/artists/album/
duration_ms — no image_url — unlike the sibling /api/itunes/ and
/api/deezer/ endpoints which include image_url. The fix modal had
no image data to work with when users searched via Spotify.
2. Frontend selectDiscoveryFixTrack discarded any image info it did get
and posted the same minimal shape to the backend.
3. All 7 backend discovery/update_match endpoints built
result['spotify_data'] with 'album' as a bare string (track.album
which is just the album name). The download pipeline expects
spotify_album to be a dict with image_url or images[].url — a string
yields blank cover art. Normal discovery workers already build album
as a rich dict; the fix-modal path was the anomaly.
4. Bonus: result['wing_it_fallback'] was never cleared on manual match.
Tracks fixed after the auto Wing-It fallback kept the flag set, so
downstream code checking it treated them as wing-it even though the
user picked real metadata.
Changes:
- New helper _build_fix_modal_spotify_data(spotify_track) in web_server.py
near _build_discovery_wing_it_stub. Handles both string and dict album
inputs, normalises to a dict with image_url and images populated when
the payload carries one. Matches the shape produced by normal discovery
so downstream code is happy on both paths.
- /api/spotify/search_tracks now returns image_url (parity with iTunes
and Deezer endpoints).
- All 7 discovery/update_match endpoints (youtube, tidal, deezer,
spotify-public, listenbrainz, beatport — 6 via the identical pattern
plus the listenbrainz variant with its None branch) now:
* use the helper to build spotify_data (album as dict + top-level
image_url)
* explicitly set result['wing_it_fallback'] = False
- selectDiscoveryFixTrack forwards track.image_url in the POST body and
mirrors the helper's output in the local state update so the UI
reflects the same shape immediately.
Audit: every downstream reader of spotify_data['album'] is already
dict-or-string tolerant (isinstance checks at lines 2073, 2158, 25844,
28938, 29011, etc.) so promoting album from string to dict is safe.
Normal discovery already sets it as a dict, so we're moving the fix-
modal path to match the existing majority case.
Full suite stays at 263 passed. Ruff clean.
This commit is contained in:
parent
e15f581b33
commit
75d0dc3d4f
2 changed files with 108 additions and 53 deletions
133
web_server.py
133
web_server.py
|
|
@ -33508,7 +33508,8 @@ def search_spotify_tracks():
|
|||
'name': t.name,
|
||||
'artists': t.artists,
|
||||
'album': t.album,
|
||||
'duration_ms': t.duration_ms
|
||||
'duration_ms': t.duration_ms,
|
||||
'image_url': getattr(t, 'image_url', None),
|
||||
} for t in tracks]
|
||||
|
||||
return jsonify({'tracks': tracks_dict})
|
||||
|
|
@ -34369,14 +34370,13 @@ def update_tidal_discovery_match():
|
|||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility.
|
||||
# Manual match from the fix modal — build a rich spotify_data (album
|
||||
# as dict with image info) matching the normal discovery shape, and
|
||||
# explicitly clear any prior wing-it flag since the user picked a
|
||||
# real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
|
||||
result['manual_match'] = True # Flag for tracking
|
||||
|
||||
|
|
@ -36038,14 +36038,13 @@ def update_deezer_discovery_match():
|
|||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility.
|
||||
# Manual match from the fix modal — build a rich spotify_data (album
|
||||
# as dict with image info) matching the normal discovery shape, and
|
||||
# explicitly clear any prior wing-it flag since the user picked a
|
||||
# real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
|
||||
result['manual_match'] = True
|
||||
|
||||
|
|
@ -36888,14 +36887,13 @@ def update_spotify_public_discovery_match():
|
|||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility.
|
||||
# Manual match from the fix modal — build a rich spotify_data (album
|
||||
# as dict with image info) matching the normal discovery shape, and
|
||||
# explicitly clear any prior wing-it flag since the user picked a
|
||||
# real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
|
||||
result['manual_match'] = True
|
||||
|
||||
|
|
@ -37838,14 +37836,13 @@ def update_youtube_discovery_match():
|
|||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility.
|
||||
# Manual match from the fix modal — build a rich spotify_data (album
|
||||
# as dict with image info) matching the normal discovery shape, and
|
||||
# explicitly clear any prior wing-it flag since the user picked a
|
||||
# real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
|
||||
result['manual_match'] = True # Flag for tracking
|
||||
|
||||
|
|
@ -37934,6 +37931,46 @@ def _build_discovery_wing_it_stub(track_name, artist_name, duration_ms=0, image_
|
|||
}
|
||||
|
||||
|
||||
def _build_fix_modal_spotify_data(spotify_track):
|
||||
"""Build a rich spotify_data dict from the fix-modal POST payload so manual
|
||||
matches carry the same shape as normal discovery results.
|
||||
|
||||
Key points:
|
||||
- album is always a dict (normal discovery has it this way; legacy fix-modal
|
||||
produced a bare string which broke cover art lookup downstream)
|
||||
- image_url is carried both at top level and inside album.images for parity
|
||||
with Spotify API responses
|
||||
- handles both legacy string albums (most search endpoints return this) and
|
||||
newer object albums
|
||||
"""
|
||||
if not isinstance(spotify_track, dict):
|
||||
spotify_track = {}
|
||||
|
||||
image_url = spotify_track.get('image_url') or ''
|
||||
album_raw = spotify_track.get('album', '')
|
||||
|
||||
if isinstance(album_raw, dict):
|
||||
album_obj = dict(album_raw)
|
||||
if image_url and not album_obj.get('image_url'):
|
||||
album_obj['image_url'] = image_url
|
||||
if image_url and not album_obj.get('images'):
|
||||
album_obj['images'] = [{'url': image_url}]
|
||||
else:
|
||||
album_obj = {'name': album_raw or ''}
|
||||
if image_url:
|
||||
album_obj['image_url'] = image_url
|
||||
album_obj['images'] = [{'url': image_url}]
|
||||
|
||||
return {
|
||||
'id': spotify_track.get('id', ''),
|
||||
'name': spotify_track.get('name', ''),
|
||||
'artists': spotify_track.get('artists', []),
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'image_url': image_url,
|
||||
}
|
||||
|
||||
|
||||
def _run_youtube_discovery_worker(url_hash):
|
||||
"""Background worker for YouTube music discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
|
|
@ -46543,17 +46580,16 @@ def update_listenbrainz_discovery_match():
|
|||
result['spotify_id'] = spotify_track.get('id', '') if spotify_track else ''
|
||||
|
||||
if spotify_track:
|
||||
# Store spotify_data in the same format as other platforms
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track.get('id', ''),
|
||||
'name': spotify_track.get('name', ''),
|
||||
'artists': artists if isinstance(artists, list) else [artists],
|
||||
'album': result['spotify_album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# Store spotify_data in the same format as other platforms.
|
||||
# Manual match from the fix modal — build a rich spotify_data
|
||||
# (album as dict with image info) matching the normal discovery
|
||||
# shape, and explicitly clear any prior wing-it flag since the
|
||||
# user picked a real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
else:
|
||||
result['spotify_data'] = None
|
||||
|
||||
result['wing_it_fallback'] = False
|
||||
result['manual_match'] = True
|
||||
|
||||
logger.info(f"Updated ListenBrainz match for track {track_index}: {result['status']}")
|
||||
|
|
@ -48757,14 +48793,13 @@ def update_beatport_discovery_match():
|
|||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility.
|
||||
# Manual match from the fix modal — build a rich spotify_data (album
|
||||
# as dict with image info) matching the normal discovery shape, and
|
||||
# explicitly clear any prior wing-it flag since the user picked a
|
||||
# real metadata match.
|
||||
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
|
||||
result['wing_it_fallback'] = False
|
||||
|
||||
result['manual_match'] = True # Flag for tracking
|
||||
|
||||
|
|
|
|||
|
|
@ -19714,7 +19714,8 @@ async function selectDiscoveryFixTrack(track) {
|
|||
name: track.name,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
duration_ms: track.duration_ms
|
||||
duration_ms: track.duration_ms,
|
||||
image_url: track.image_url || null
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -19782,14 +19783,33 @@ async function selectDiscoveryFixTrack(track) {
|
|||
result.spotify_id = track.id;
|
||||
result.duration = formatDuration(track.duration_ms);
|
||||
result.manual_match = true;
|
||||
// User picked a real metadata match — no longer a wing-it track
|
||||
result.wing_it_fallback = false;
|
||||
|
||||
// IMPORTANT: Also set spotify_data for download/sync compatibility
|
||||
// IMPORTANT: Also set spotify_data for download/sync compatibility.
|
||||
// Build album as a dict (not a bare string) so the download
|
||||
// pipeline can find cover art via album.image_url / album.images.
|
||||
// This matches the shape that normal discovery produces.
|
||||
const _fixImageUrl = track.image_url || '';
|
||||
let _fixAlbumObj;
|
||||
if (track.album && typeof track.album === 'object') {
|
||||
_fixAlbumObj = { ...track.album };
|
||||
if (_fixImageUrl && !_fixAlbumObj.image_url) _fixAlbumObj.image_url = _fixImageUrl;
|
||||
if (_fixImageUrl && !_fixAlbumObj.images) _fixAlbumObj.images = [{ url: _fixImageUrl }];
|
||||
} else {
|
||||
_fixAlbumObj = { name: track.album || '' };
|
||||
if (_fixImageUrl) {
|
||||
_fixAlbumObj.image_url = _fixImageUrl;
|
||||
_fixAlbumObj.images = [{ url: _fixImageUrl }];
|
||||
}
|
||||
}
|
||||
result.spotify_data = {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
artists: track.artists,
|
||||
album: track.album,
|
||||
duration_ms: track.duration_ms
|
||||
album: _fixAlbumObj,
|
||||
duration_ms: track.duration_ms,
|
||||
image_url: _fixImageUrl
|
||||
};
|
||||
|
||||
// Increment match count if this was previously not_found or error
|
||||
|
|
|
|||
Loading…
Reference in a new issue