diff --git a/core/metadata/relevance.py b/core/metadata/relevance.py index 43ab430b..b6f0e069 100644 --- a/core/metadata/relevance.py +++ b/core/metadata/relevance.py @@ -52,6 +52,27 @@ from typing import List, Optional, Sequence from core.metadata.types import Track +def build_combined_search_query(track: str = '', artist: str = '', + legacy: str = '') -> str: + """Combine a track + artist into a PLAIN, source-agnostic search query. + + Deliberately NOT field-scoped (``track:"X" artist:"Y"``). That Spotify/Lucene + filter syntax leaks to non-Spotify sources when a search falls back: Deezer + closed the connection on it outright (``RemoteDisconnected``), and even sources + that parse it over-constrain and miss the canonical cut (the iTunes/Deezer search + endpoints already dropped it for that reason). The matching ``/search_tracks`` + endpoints rerank by expected title/artist afterward, so precision is recovered + without the brittle field syntax. + + Falls back to ``legacy`` when neither track nor artist is given. Returns ``''`` + when there's nothing to search (caller decides how to handle empty). + """ + parts = [p.strip() for p in (track, artist) if p and p.strip()] + if parts: + return ' '.join(parts) + return (legacy or '').strip() + + # --------------------------------------------------------------------------- # Pattern tables — public so tests can introspect, callers can extend # --------------------------------------------------------------------------- diff --git a/tests/metadata/test_relevance.py b/tests/metadata/test_relevance.py index 4c6f9394..7692a2b5 100644 --- a/tests/metadata/test_relevance.py +++ b/tests/metadata/test_relevance.py @@ -447,3 +447,36 @@ class TestFilterAndRerank: # Karaoke pattern reduces score by 0.05x — well below 0.5 assert all(t.id != 'karaoke-id' for t in result) assert any(t.id == 'real-id' for t in result) + + +# ── build_combined_search_query: plain, source-agnostic queries (pool-fix bug) ── + +from core.metadata.relevance import build_combined_search_query as _bcsq + + +def test_combined_query_is_plain_not_field_scoped(): + # THE fix: must NOT emit Spotify `track:`/`artist:` syntax — that leaked to Deezer + # (which aborted the connection) and other fallbacks that can't parse it. + q = _bcsq('Not Like Us', 'Kendrick Lamar') + assert q == 'Not Like Us Kendrick Lamar' + assert 'track:' not in q and 'artist:' not in q + + +def test_combined_query_track_or_artist_alone(): + assert _bcsq('Not Like Us', '') == 'Not Like Us' + assert _bcsq('', 'Kendrick Lamar') == 'Kendrick Lamar' + + +def test_combined_query_trims_whitespace(): + assert _bcsq(' Not Like Us ', ' Kendrick Lamar ') == 'Not Like Us Kendrick Lamar' + + +def test_combined_query_falls_back_to_legacy(): + assert _bcsq('', '', 'free text search') == 'free text search' + # track/artist win over legacy when present + assert _bcsq('A', 'B', 'ignored') == 'A B' + + +def test_combined_query_empty_when_nothing(): + assert _bcsq('', '', '') == '' + assert _bcsq(' ', '', ' ') == '' diff --git a/web_server.py b/web_server.py index ddf4b32e..9b8719af 100644 --- a/web_server.py +++ b/web_server.py @@ -21612,17 +21612,13 @@ def search_spotify_tracks(): legacy_query = request.args.get('query', '').strip() limit = int(request.args.get('limit', 20)) - # Build Spotify field-filtered query - if track_q or artist_q: - parts = [] - if track_q: - parts.append(f'track:{track_q}') - if artist_q: - parts.append(f'artist:{artist_q}') - query = ' '.join(parts) - elif legacy_query: - query = legacy_query - else: + # Plain combined query — NOT field-scoped (`track:X artist:Y`). That Spotify + # syntax leaks to non-Spotify sources when search falls back (Deezer aborted + # the connection on it); the iTunes/Deezer endpoints already dropped it for the + # same reason, and the rerank below recovers precision. (Pool-fix "no results".) + from core.metadata.relevance import build_combined_search_query + query = build_combined_search_query(track_q, artist_q, legacy_query) + if not query: return jsonify({"error": "Query parameter is required"}), 400 if use_hydrabase: diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9073be1a..e21fb3ce 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1860,9 +1860,17 @@ async function searchPoolFix() { if (artistVal) params.set('artist', artistVal); params.set('limit', '20'); const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`); - const data = await res.json(); - const tracks = data.tracks || []; + const data = await res.json().catch(() => ({})); + // Surface the real failure instead of masking every error (auth, 500, an + // upstream connection abort) as a bland "No results found". + if (!res.ok || data.error) { + const msg = data.error || res.statusText || `request failed (${res.status})`; + resultsContainer.innerHTML = `