diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 32fe809b..db6e53aa 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -398,6 +398,154 @@ def get_playlist_states( return {"error": str(e)}, 500 +def first_artist_str_or_obj(original_track: Dict[str, Any]) -> str: + """Tidal: first artist from an artists list that may hold strings OR + objects ({'name': ...}); '' when empty.""" + artists = original_track.get('artists', []) + if artists: + return artists[0] if isinstance(artists[0], str) else artists[0].get('name', '') + return '' + + +def first_artist_plain(original_track: Dict[str, Any]) -> str: + """Deezer/Qobuz/Spotify-Public: first artist assuming a list of strings; + '' when empty.""" + artists = original_track.get('artists', []) + return artists[0] if artists else '' + + +def update_discovery_match( + states: Dict[str, Any], + get_json, + *, + source_log_label: str, + error_label: str, + original_track_key: str, + original_artist_getter, + join_artist_names, + extract_artist_name, + build_fix_modal_spotify_data, + get_discovery_cache_key, + get_database, + get_active_discovery_source, +) -> Tuple[Dict[str, Any], int]: + """Apply a manually-selected Spotify track to a discovery result (the + fix-modal flow) and persist it to the discovery cache. + + 1:1 lift of the ``update__discovery_match`` bodies for the four + sources with the identical structure (Tidal, Deezer, Qobuz, Spotify-Public). + Per-source pieces are params: + + - ``source_log_label`` (lowercase, e.g. "tidal") for the "Manual match + updated: ..." line; ``error_label`` for the except log. + - ``original_track_key`` — the raw-source track key on the result + ('tidal_track', 'deezer_track', ...). + - ``original_artist_getter`` — Tidal handles string-or-object artists + (``first_artist_str_or_obj``); the rest assume strings + (``first_artist_plain``). + - the web_server helpers (join/extract artist, build_fix_modal_spotify_data, + cache-key, get_database, active-discovery-source) are injected so this + stays free of those globals. + - ``get_json`` is called INSIDE the try (like the original's + ``request.get_json()``) so a malformed body yields the same 500. + + Returns ``(payload, status_code)``. + + NOT folded in: iTunes-Link (saves spotify_data directly via a different + cache signature), YouTube (multi-key original_track fallback), ListenBrainz + (entirely different unmatch-capable structure, no cache write), Beatport. + """ + try: + data = get_json() + identifier = data.get('identifier') + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return {'error': 'Missing required fields'}, 400 + + state = states.get(identifier) + if not state: + return {'error': 'Discovery state not found'}, 404 + + 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['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' + + 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 + + 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) + + cache_key = get_discovery_cache_key(original_name, original_artist) + artists_list = spotify_track['artists'] + if isinstance(artists_list, list): + artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] + 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}] + + matched_data = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': artists_list, + 'album': album_obj, + 'duration_ms': spotify_track.get('duration_ms', 0), + 'image_url': image_url, + 'source': 'spotify', + } + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], get_active_discovery_source(), 1.0, matched_data, + original_name, original_artist + ) + logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") + except Exception as cache_err: + logger.error(f"Error saving manual fix to discovery cache: {cache_err}") + + return {'success': True, 'result': result}, 200 + except Exception as e: + logger.error(f"Error updating {error_label} discovery match: {e}") + return {'error': str(e)}, 500 + + def start_sync( states: Dict[str, Any], key: str, diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 219dfd77..74932087 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -652,3 +652,141 @@ def test_start_sync_exception_returns_500(): states = {'pl': {'phase': 'discovered', 'discovery_results': [1]}} body, code = start_sync(states, 'pl', **kw) assert code == 500 and "error" in body + + +# --------------------------------------------------------------------------- +# first_artist extractors +# --------------------------------------------------------------------------- + +def test_first_artist_str_or_obj(): + from core.discovery.endpoints import first_artist_str_or_obj as g + assert g({'artists': ['A', 'B']}) == 'A' + assert g({'artists': [{'name': 'Obj'}]}) == 'Obj' + assert g({'artists': []}) == '' + assert g({}) == '' + + +def test_first_artist_plain(): + from core.discovery.endpoints import first_artist_plain as g + assert g({'artists': ['A', 'B']}) == 'A' + assert g({'artists': []}) == '' + assert g({}) == '' + + +# --------------------------------------------------------------------------- +# update_discovery_match +# --------------------------------------------------------------------------- + +class _FakeCacheDB: + def __init__(self): + self.saved = [] + + def save_discovery_cache_match(self, *args): + self.saved.append(args) + + +def _update_kwargs(*, json_data, cache_db=None, getter=None): + from core.discovery.endpoints import first_artist_plain + db = cache_db or _FakeCacheDB() + kw = dict( + source_log_label='tidal', error_label='Tidal', + original_track_key='tidal_track', + original_artist_getter=getter or first_artist_plain, + join_artist_names=lambda arts: ", ".join(arts), + extract_artist_name=lambda a: str(a), + build_fix_modal_spotify_data=lambda st: {'built': st['id']}, + get_discovery_cache_key=lambda name, artist: (name.lower(), artist.lower()), + get_database=lambda: db, + get_active_discovery_source=lambda: 'spotify', + ) + return (lambda: json_data), kw, db + + +def test_update_match_missing_fields(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p'}) # missing track_index/spotify_track + body, code = update_discovery_match({}, gj, **kw) + assert code == 400 and body == {'error': 'Missing required fields'} + + +def test_update_match_state_not_found(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': {'id': 'x'}}) + body, code = update_discovery_match({}, gj, **kw) + assert code == 404 and body == {'error': 'Discovery state not found'} + + +def test_update_match_invalid_index(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 5, + 'spotify_track': {'id': 'x', 'name': 'n', 'artists': [], 'album': 'a'}}) + states = {'p': {'discovery_results': []}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 400 and body == {'error': 'Invalid track index'} + + +def test_update_match_happy_path_full(): + from core.discovery.endpoints import update_discovery_match + sp = {'id': 'sp9', 'name': 'New Song', 'artists': ['Art1', 'Art2'], + 'album': 'Alb', 'duration_ms': 185000, 'image_url': 'cov.jpg'} + gj, kw, db = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': sp}) + result = {'status': 'not_found', 'tidal_track': {'name': 'Orig', 'artists': ['OrigArt']}} + states = {'p': {'discovery_results': [result], 'spotify_matches': 2}} + + body, code = update_discovery_match(states, gj, **kw) + + assert code == 200 and body['success'] is True + assert result['status'] == 'Found' + assert result['status_class'] == 'found' + assert result['spotify_track'] == 'New Song' + assert result['spotify_artist'] == 'Art1, Art2' + assert result['spotify_id'] == 'sp9' + assert result['duration'] == '3:05' + assert result['spotify_data'] == {'built': 'sp9'} + assert result['wing_it_fallback'] is False + assert result['manual_match'] is True + assert states['p']['spotify_matches'] == 3 # incremented (was not found) + # cache saved with normalized key + matched_data carrying image + assert len(db.saved) == 1 + key0, key1, source, score, matched, oname, oartist = db.saved[0] + assert (key0, key1) == ('orig', 'origart') + assert source == 'spotify' and score == 1.0 + assert matched['album'] == {'name': 'Alb', 'image_url': 'cov.jpg', 'images': [{'url': 'cov.jpg'}]} + assert oname == 'Orig' and oartist == 'OrigArt' + + +def test_update_match_no_increment_when_already_found(): + from core.discovery.endpoints import update_discovery_match + sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0} + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp}) + result = {'status': 'Found', 'tidal_track': {}} + states = {'p': {'discovery_results': [result], 'spotify_matches': 5}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 200 + assert states['p']['spotify_matches'] == 5 # unchanged + assert result['duration'] == '0:00' + + +def test_update_match_cache_error_is_swallowed(): + from core.discovery.endpoints import update_discovery_match + class _BoomDB: + def save_discovery_cache_match(self, *a): + raise RuntimeError("db down") + sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0} + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp}, + cache_db=_BoomDB()) + states = {'p': {'discovery_results': [{'status': 'x', 'tidal_track': {}}], 'spotify_matches': 0}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 200 and body['success'] is True # cache failure doesn't fail the request + + +def test_update_match_get_json_raises_returns_500(): + from core.discovery.endpoints import update_discovery_match + def boom(): + raise ValueError("bad json") + _, kw, _ = _update_kwargs(json_data={}) + body, code = update_discovery_match({}, boom, **kw) + assert code == 500 and 'error' in body diff --git a/web_server.py b/web_server.py index 7540989a..de9809dd 100644 --- a/web_server.py +++ b/web_server.py @@ -20566,117 +20566,7 @@ def get_tidal_discovery_status(playlist_id): @app.route('/api/tidal/discovery/update_match', methods=['POST']) def update_tidal_discovery_match(): """Update a Tidal discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = tidal_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - 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'] - - # Format duration (Tidal doesn't show duration in table, but store it anyway) - 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' - - # 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 - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: tidal - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('tidal_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artist = '' - original_artists = original_track.get('artists', []) - if original_artists: - original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '') - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - 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}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Tidal discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(tidal_discovery_states, "tidal", "Tidal", "tidal_track", _first_artist_str_or_obj) @app.route('/api/tidal/playlists/states', methods=['GET']) @@ -20988,12 +20878,15 @@ from core.discovery.endpoints import ( reset_playlist as _reset_playlist_core, get_playlist_states as _get_playlist_states_core, start_sync as _start_sync_core, + update_discovery_match as _update_discovery_match_core, playlist_name_attr_or_unknown as _pl_name_attr_or_unknown, playlist_name_strict as _pl_name_strict, playlist_name_safe as _pl_name_safe, playlist_name_obj as _pl_name_obj, playlist_image_obj as _pl_image_obj, playlist_image_dict as _pl_image_dict, + first_artist_str_or_obj as _first_artist_str_or_obj, + first_artist_plain as _first_artist_plain, ) @@ -21082,6 +20975,22 @@ def _start_source_sync(states, key, *, sync_id_prefix, not_found_message, return jsonify(body), code +def _update_source_discovery_match(states, source_log_label, error_label, + original_track_key, artist_getter): + """Thin glue for the per-source update_*_discovery_match (fix-modal) routes + (Tidal/Deezer/Qobuz/Spotify-Public) — injects the web_server helpers.""" + body, code = _update_discovery_match_core( + states, lambda: request.get_json(), + source_log_label=source_log_label, error_label=error_label, + original_track_key=original_track_key, original_artist_getter=artist_getter, + join_artist_names=_join_artist_names, extract_artist_name=_extract_artist_name, + build_fix_modal_spotify_data=_build_fix_modal_spotify_data, + get_discovery_cache_key=_get_discovery_cache_key, get_database=get_database, + get_active_discovery_source=_get_active_discovery_source, + ) + return jsonify(body), code + + def _build_tidal_discovery_deps(): """Build the TidalDiscoveryDeps bundle from web_server.py globals on each call.""" return _discovery_tidal.TidalDiscoveryDeps( @@ -21364,115 +21273,7 @@ def get_deezer_discovery_status(playlist_id): @app.route('/api/deezer/discovery/update_match', methods=['POST']) def update_deezer_discovery_match(): """Update a Deezer discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = deezer_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - 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'] - - # Format duration - 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' - - # 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 - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: deezer - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('deezer_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - 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}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Deezer discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(deezer_discovery_states, "deezer", "Deezer", "deezer_track", _first_artist_plain) @app.route('/api/deezer/playlists/states', methods=['GET']) def get_deezer_playlist_states(): @@ -21814,101 +21615,8 @@ def get_qobuz_discovery_status(playlist_id): @app.route('/api/qobuz/discovery/update_match', methods=['POST']) def update_qobuz_discovery_match(): - """Update a Qobuz discovery result with a manually selected Spotify track.""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - state = qobuz_discovery_states.get(identifier) - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - 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'] - - 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' - - # Manual match from the fix modal — build rich spotify_data matching - # the normal discovery shape, clear 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 - - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: qobuz - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - try: - original_track = result.get('qobuz_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - 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}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Qobuz discovery match: {e}") - return jsonify({'error': str(e)}), 500 + """Update a Qobuz discovery result with manually selected Spotify track""" + return _update_source_discovery_match(qobuz_discovery_states, "qobuz", "Qobuz", "qobuz_track", _first_artist_plain) @app.route('/api/qobuz/playlists/states', methods=['GET']) @@ -22524,115 +22232,7 @@ def get_spotify_public_discovery_status(url_hash): @app.route('/api/spotify-public/discovery/update_match', methods=['POST']) def update_spotify_public_discovery_match(): """Update a Spotify Public discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # url_hash - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = spotify_public_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - 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'] - - # Format duration - 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' - - # 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 - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: spotify_public - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('spotify_public_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - 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}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Spotify Public discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(spotify_public_discovery_states, "spotify_public", "Spotify Public", "spotify_public_track", _first_artist_plain) @app.route('/api/spotify-public/playlists/states', methods=['GET']) def get_spotify_public_playlist_states():