From 75d0dc3d4fdc6ba26cf77facd864f16e1f856135 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:12:28 -0700 Subject: [PATCH] Fix manual discovery match producing download cards with no cover art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web_server.py | 133 ++++++++++++++++++++++++++--------------- webui/static/script.js | 28 +++++++-- 2 files changed, 108 insertions(+), 53 deletions(-) diff --git a/web_server.py b/web_server.py index d087ed37..7c2b6624 100644 --- a/web_server.py +++ b/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 diff --git a/webui/static/script.js b/webui/static/script.js index 950b5f31..16954d75 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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