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:
Broque Thomas 2026-04-21 17:12:28 -07:00
parent e15f581b33
commit 75d0dc3d4f
2 changed files with 108 additions and 53 deletions

View file

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

View file

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