From 0afa3c9705b4a3b6a73463ced7dc05236107a3b2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 18:49:32 -0700 Subject: [PATCH] Fix: "Discovery state not found" when fixing a match after restart/import (#843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery FIX → Confirm flow 404'd with "Discovery state not found" whenever the in-memory discovery state was gone — a server restart, or an imported playlist that wasn't discovered in THIS process — even though the card is still shown from persisted data (the reporter's log shows "Returning 0 stored ... playlists for hydration", i.e. the in-memory states were empty). The thing that actually makes a manual fix STICK is writing it to the discovery cache (save_discovery_cache_match), keyed by the original track's name + artist — which doesn't need the in-memory state at all. But the endpoint 404'd on the missing state before reaching that write, so the fix was dead after a restart. - update_discovery_match (core/discovery/endpoints.py) now only does the in-memory result update when the state exists; the durable discovery-cache write always runs, falling back to client-provided original_name/original_artist when there's no in-memory state. With neither a state nor originals it still 404s (unchanged). - The FIX confirm (wishlist-tools.js) now sends original_name/original_artist (from the source track it already has) so the backend can key the cache. Covers all sources that share the helper (tidal/deezer/qobuz/spotify-public). Tests: no-state-but-originals saves the cache + returns success; no-state-no- originals still 404s; existing with-state path unchanged. 73 discovery tests pass. --- core/discovery/endpoints.py | 73 +++++++++++++-------- tests/discovery/test_discovery_endpoints.py | 36 ++++++++++ webui/static/wishlist-tools.js | 5 ++ 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 69dbb0d8..cd0f0190 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -579,45 +579,62 @@ def update_discovery_match( return {'error': 'Missing required fields'}, 400 state = states.get(identifier) - if not state: - return {'error': 'Discovery state not found'}, 404 + result = None + if state: + if track_index >= len(state['discovery_results']): + return {'error': 'Invalid track index'}, 400 - if track_index >= len(state['discovery_results']): - return {'error': 'Invalid track index'}, 400 + result = state['discovery_results'][track_index] + old_status = result.get('status') - result = state['discovery_results'][track_index] - old_status = result.get('status') + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' + result['spotify_data'] = build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True - result['spotify_data'] = build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - result['manual_match'] = True + if old_status != 'found' and old_status != 'Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}") + logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - try: original_track = result.get(original_track_key, {}) original_name = original_track.get('name', spotify_track['name']) original_artist = original_artist_getter(original_track) + else: + # #843: the in-memory discovery state can be gone — a server restart, + # or an imported playlist that wasn't discovered in THIS process — + # while the card is still shown from persisted data. The DURABLE part + # of a manual fix (writing the match to the discovery cache so future + # syncs resolve it) doesn't need the in-memory state, only the original + # track's name + artist, which the client now sends. Fall back to those + # instead of 404ing the fix into uselessness. + original_name = (data.get('original_name') or '').strip() + original_artist = (data.get('original_artist') or '').strip() + if not original_name and not original_artist: + return {'error': 'Discovery state not found'}, 404 + if not original_name: + original_name = spotify_track['name'] + logger.info( + f"Manual match (no in-memory state) → discovery cache: " + f"{source_log_label} - {identifier} - '{original_name}' by '{original_artist}'" + ) + try: cache_key = get_discovery_cache_key(original_name, original_artist) artists_list = spotify_track['artists'] if isinstance(artists_list, list): diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 2f1b0ace..d4ad7f2d 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -955,3 +955,39 @@ def test_snapshot_exception_returns_500(): raise RuntimeError('db down') body, code = save_bubble_snapshot(lambda: {'bubbles': [1]}, **_snap_kwargs(_BoomDB())) assert code == 500 and body == {'success': False, 'error': 'db down'} + + +def test_update_match_no_state_but_originals_saves_cache(): + """#843: the in-memory discovery state can be gone (server restart, or an + imported playlist not discovered this run) while the card is still shown from + persisted data. A manual fix must still save the match to the discovery cache + from the client-provided originals instead of 404ing.""" + from core.discovery.endpoints import update_discovery_match + db = _FakeCacheDB() + gj, kw, _ = _update_kwargs(cache_db=db, json_data={ + 'identifier': 'gone-from-memory', 'track_index': 0, + 'original_name': 'Acid Dream', 'original_artist': 'Cherrymoon Traxx', + 'spotify_track': { + 'id': 'sp1', 'name': 'Acid Dream (The Prophet remix)', + 'artists': ['Cherry Moon Trax'], 'album': 'X', 'duration_ms': 1000, + }, + }) + body, code = update_discovery_match({}, gj, **kw) # empty in-memory states + + assert code == 200 and body['success'] is True + assert body['result'] is None # nothing to update in memory + # the durable cache match WAS written, keyed by the original track + assert len(db.saved) == 1 + saved = db.saved[0] + assert saved[0] == 'acid dream' # cache_key from get_discovery_cache_key + assert saved[5] == 'Acid Dream' and saved[6] == 'Cherrymoon Traxx' # original name/artist + + +def test_update_match_no_state_and_no_originals_still_404(): + """Without an in-memory state AND without originals to key the cache, there's + genuinely nothing to do — keep the 404 (the existing safety).""" + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'gone', 'track_index': 0, 'spotify_track': {'id': 'x'}}) + body, code = update_discovery_match({}, gj, **kw) + assert code == 404 and body == {'error': 'Discovery state not found'} diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 1842e72a..64230654 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -410,6 +410,11 @@ async function selectDiscoveryFixTrack(track) { const requestBody = { identifier: backendIdentifier, track_index: trackIndex, + // #843: send the original (source) track so the backend can still save + // the match to the discovery cache when its in-memory discovery state + // is gone (server restart / imported playlist not discovered this run). + original_name: currentDiscoveryFix.sourceTrack || '', + original_artist: currentDiscoveryFix.sourceArtist || '', spotify_track: { id: track.id, name: track.name,