Discovery lift (8/N): update_*_discovery_match -> shared helper
Eighth cluster, the heavyweights (~110 lines each). The fix-modal
update_<source>_discovery_match for the four sources with the identical
structure (Tidal, Deezer, Qobuz, Spotify-Public) ->
core.discovery.endpoints.update_discovery_match(...), wired via
_update_source_discovery_match. Applies the user-selected Spotify track to the
discovery result (status/artist/album/duration/spotify_data/match-count) and
writes the manual fix to the discovery cache.
Per-source pieces are params:
- source_log_label / error_label.
- original_track_key ('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).
- web_server helpers (join/extract artist, build_fix_modal_spotify_data,
cache-key, get_database, active-discovery-source) injected.
- get_json passed as a callable and invoked INSIDE the try, preserving the
original's "request.get_json() inside try" behavior (malformed body -> 500).
NOT folded in (genuinely divergent): 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.
Tests: +9 (extractors; 400/404/400 guards; full happy path with result
mutation + duration formatting + match-count + cache-save args; no-increment
when already found; cache error swallowed; get_json raise -> 500). Full
discovery suite: 198 passed.
web_server.py: -400 lines.
This commit is contained in:
parent
17c9e9b7b9
commit
50ebfbd82f
3 changed files with 310 additions and 424 deletions
|
|
@ -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_<source>_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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
448
web_server.py
448
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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue