Live-API verification revealed advanced-syntax queries hurt more than they help on this endpoint. Switching the import-modal Deezer search back to free-text + local rerank. # What live testing showed Hit Deezer's public API with both query forms for the issue #534 case (`Dirty White Boy` + `Foreigner`): **Free-text (`q=Dirty White Boy Foreigner`):** - Returns 21 results - Real Foreigner Head Games studio cut at #1 - Live versions at #2-10 - Karaoke / cover variants at #11-15 **Advanced (`q=track:"Dirty White Boy" artist:"Foreigner"`):** - Returns 12 results - "(2008 Remaster)" at #1 — canonical Head Games cut MISSING from top 8 entirely - Live + alt-album versions follow Advanced syntax DOES filter karaoke at the API level (none in the 12-result set vs. 5 at positions 11-15 in free-text), but it has its own ranking bias that surfaces remasters / "Best Of" cuts ahead of the canonical recording. Net regression for the user- facing goal. # Fix 1. Endpoint reverts to free-text query with local rerank applied. 2. Local rerank gains "remaster" / "remastered" / "reissue" patterns under VARIANT_TAG_PATTERNS (soft 0.4× penalty — user may want them but they shouldn't outrank the original). 3. Client kwarg support (`track=` / `artist=` / `album=`) preserved for future opt-in callers (e.g. exact-match flows where API- level filtering matters more than ranking). # Verified end-to-end against live Deezer API Re-ran the exact #534 case through the live API + new rerank. Top 15 results post-rerank: 1. Dirty White Boy — Foreigner — Head Games ← REAL CUT AT TOP 2-10. Various Live versions 11-15. Karaoke / cover / tribute variants ← BURIED Real Foreigner Head Games studio cut at #1, exactly the user's ask. # Tests - `test_relevance.py` — variant tag patterns extended; existing tests still pass (50 tests). - `test_search_match_endpoints.py::test_joins_track_and_artist_into_free_text_query` — replaces `test_passes_track_and_artist_as_kwargs`; verifies endpoint sends free-text join, NOT field-scoped kwargs (the prior test asserted the wrong direction now). - Karaoke-burying assertion at the endpoint still pins the user-visible behaviour. - Client kwarg path tests untouched (still pin advanced-syntax construction for future opt-in callers). # Verification - 75 relevance + endpoint + query tests pass - 2445 full suite passes - Ruff clean - Live Deezer API shows real cut at #1 post-rerank
213 lines
9.8 KiB
Python
213 lines
9.8 KiB
Python
"""End-to-end tests for the import-modal search endpoints.
|
|
|
|
Issue #534 — these endpoints back the "Search for Match" dialog
|
|
that lets users find a track when auto-match failed. They were
|
|
returning karaoke / cover variants ahead of canonical recordings
|
|
because:
|
|
|
|
1. Deezer endpoint joined `track + artist` into a single free-text
|
|
string, losing field-scoping.
|
|
2. None of the endpoints applied any local relevance rerank, so
|
|
junk results stayed wherever the source's API put them.
|
|
|
|
These tests pin the post-fix wiring:
|
|
- Deezer endpoint passes `track=` + `artist=` kwargs to the client
|
|
(which now builds advanced-syntax `track:"X" artist:"Y"`).
|
|
- Deezer + iTunes + Spotify endpoints all run the response through
|
|
``rerank_tracks`` so karaoke / cover patterns drop to the bottom.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def app_test_client():
|
|
"""Spin up a Flask test client backed by web_server.app."""
|
|
import web_server
|
|
web_server.app.config['TESTING'] = True
|
|
with web_server.app.test_client() as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_track():
|
|
"""Factory for a Track-like object the endpoints can serialise."""
|
|
from core.metadata.types import Track
|
|
|
|
def _make(name, artist, album='Album', track_id='t', album_type='album'):
|
|
return Track(
|
|
id=track_id, name=name, artists=[artist],
|
|
album=album, duration_ms=200000, album_type=album_type,
|
|
)
|
|
return _make
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/deezer/search_tracks — field-scoped + rerank
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeezerSearchTracksEndpoint:
|
|
def test_joins_track_and_artist_into_free_text_query(self, app_test_client, fake_track):
|
|
"""Endpoint sends the joined `track artist` string as Deezer's
|
|
free-text `q`. Field-scoped advanced-syntax queries were
|
|
initially considered, but live-API testing showed Deezer's
|
|
advanced-query ranking misses canonical recordings on some
|
|
searches. Free-text + local rerank is the more reliable
|
|
combination at this endpoint. Client-level kwarg support
|
|
remains for future opt-in callers."""
|
|
fake_client = MagicMock()
|
|
fake_client.search_tracks.return_value = [
|
|
fake_track('Dirty White Boy', 'Foreigner'),
|
|
]
|
|
with patch('web_server._get_deezer_client', return_value=fake_client):
|
|
resp = app_test_client.get(
|
|
'/api/deezer/search_tracks?track=Dirty+White+Boy&artist=Foreigner&limit=20'
|
|
)
|
|
assert resp.status_code == 200
|
|
call = fake_client.search_tracks.call_args
|
|
# First positional arg is the joined free-text query
|
|
assert call.args[0] == 'Dirty White Boy Foreigner'
|
|
assert call.kwargs.get('limit') == 20
|
|
|
|
def test_reranks_results_burying_karaoke(self, app_test_client, fake_track):
|
|
"""Endpoint runs results through rerank_tracks. Real Foreigner
|
|
cut must end up first, karaoke variant last — even though the
|
|
client returned them in the broken Deezer-API order."""
|
|
fake_client = MagicMock()
|
|
fake_client.search_tracks.return_value = [
|
|
fake_track('Dirty White Boy (Karaoke Version Originally Performed By Foreigner)',
|
|
'Pop Music Workshop', album='Backing Tracks',
|
|
album_type='compilation', track_id='karaoke-1'),
|
|
fake_track('Dirty White Boy', 'Foreigner', album='Head Games',
|
|
album_type='album', track_id='real-1'),
|
|
]
|
|
with patch('web_server._get_deezer_client', return_value=fake_client):
|
|
resp = app_test_client.get(
|
|
'/api/deezer/search_tracks?track=Dirty+White+Boy&artist=Foreigner'
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
ids = [t['id'] for t in body['tracks']]
|
|
assert ids[0] == 'real-1', (
|
|
f"Real cut should be first after rerank; got order {ids}"
|
|
)
|
|
assert ids[-1] == 'karaoke-1', (
|
|
f"Karaoke variant should be last; got order {ids}"
|
|
)
|
|
|
|
def test_legacy_query_param_still_works(self, app_test_client, fake_track):
|
|
"""Backward compat: callers passing the legacy `query=` param
|
|
get free-text search, no rerank (no signal to rank against)."""
|
|
fake_client = MagicMock()
|
|
fake_client.search_tracks.return_value = [
|
|
fake_track('Anything', 'Whatever', track_id='only'),
|
|
]
|
|
with patch('web_server._get_deezer_client', return_value=fake_client):
|
|
resp = app_test_client.get(
|
|
'/api/deezer/search_tracks?query=anything+whatever'
|
|
)
|
|
assert resp.status_code == 200
|
|
# Legacy path passes positional query, no track/artist kwargs
|
|
call = fake_client.search_tracks.call_args
|
|
assert call.args[0] == 'anything whatever' or call.kwargs.get('query') == 'anything whatever'
|
|
|
|
def test_missing_query_returns_400(self, app_test_client):
|
|
"""Empty input → 400. Don't waste an API call."""
|
|
with patch('web_server._get_deezer_client', return_value=MagicMock()):
|
|
resp = app_test_client.get('/api/deezer/search_tracks')
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/itunes/search_tracks — rerank applied even though iTunes has no
|
|
# advanced-syntax search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestiTunesSearchTracksEndpoint:
|
|
def test_reranks_results_burying_karaoke(self, app_test_client, fake_track, monkeypatch):
|
|
"""iTunes API doesn't expose field-scoped search, but rerank
|
|
still applies — local relevance still penalises karaoke /
|
|
cover patterns regardless of source."""
|
|
fake_client = MagicMock()
|
|
fake_client.search_tracks.return_value = [
|
|
fake_track('Dirty White Boy (Karaoke Version)',
|
|
'Karaoke Co', track_id='karaoke-1',
|
|
album='Karaoke Hits', album_type='compilation'),
|
|
fake_track('Dirty White Boy', 'Foreigner',
|
|
album='Head Games', album_type='album',
|
|
track_id='real-1'),
|
|
]
|
|
# Endpoint dispatches via _get_metadata_fallback_client; stub it
|
|
monkeypatch.setattr('web_server._get_metadata_fallback_client', lambda: fake_client)
|
|
monkeypatch.setattr('web_server._get_metadata_fallback_source', lambda: 'itunes')
|
|
monkeypatch.setattr('web_server._is_hydrabase_active', lambda: False)
|
|
# Avoid hydrabase worker side-effect during test
|
|
monkeypatch.setattr('web_server.hydrabase_worker', None, raising=False)
|
|
monkeypatch.setattr('web_server.dev_mode_enabled', False, raising=False)
|
|
|
|
resp = app_test_client.get(
|
|
'/api/itunes/search_tracks?track=Dirty+White+Boy&artist=Foreigner'
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
ids = [t['id'] for t in body['tracks']]
|
|
assert ids[0] == 'real-1', (
|
|
f"Real cut should be first after rerank; got {ids}"
|
|
)
|
|
|
|
def test_legacy_query_param_skips_rerank(self, app_test_client, fake_track, monkeypatch):
|
|
"""Free-text query has no expected title/artist to rank
|
|
against — rerank is a no-op (returns input order)."""
|
|
# Order: A first, B second — must stay in that order.
|
|
a = fake_track('Anything', 'X', track_id='first')
|
|
b = fake_track('Whatever', 'Y', track_id='second')
|
|
fake_client = MagicMock()
|
|
fake_client.search_tracks.return_value = [a, b]
|
|
monkeypatch.setattr('web_server._get_metadata_fallback_client', lambda: fake_client)
|
|
monkeypatch.setattr('web_server._get_metadata_fallback_source', lambda: 'itunes')
|
|
monkeypatch.setattr('web_server._is_hydrabase_active', lambda: False)
|
|
monkeypatch.setattr('web_server.hydrabase_worker', None, raising=False)
|
|
monkeypatch.setattr('web_server.dev_mode_enabled', False, raising=False)
|
|
|
|
resp = app_test_client.get('/api/itunes/search_tracks?query=anything')
|
|
body = resp.get_json()
|
|
assert [t['id'] for t in body['tracks']] == ['first', 'second']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/spotify/search_tracks — already builds field-scoped query;
|
|
# verify rerank also applies for consistency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSpotifySearchTracksEndpoint:
|
|
def test_reranks_results(self, app_test_client, fake_track, monkeypatch):
|
|
"""Spotify endpoint already builds `track:X artist:Y` query
|
|
syntax. Rerank still applies as the safety net for any
|
|
karaoke / cover that slips through."""
|
|
fake_client = MagicMock()
|
|
fake_client.is_authenticated.return_value = True
|
|
fake_client.search_tracks.return_value = [
|
|
fake_track('Track (Karaoke)', 'Karaoke Co', track_id='karaoke-1',
|
|
album='Karaoke Hits', album_type='compilation'),
|
|
fake_track('Track', 'Real Artist', album='Album',
|
|
album_type='album', track_id='real-1'),
|
|
]
|
|
monkeypatch.setattr('web_server.spotify_client', fake_client, raising=False)
|
|
monkeypatch.setattr('web_server._is_hydrabase_active', lambda: False)
|
|
monkeypatch.setattr('web_server.hydrabase_worker', None, raising=False)
|
|
monkeypatch.setattr('web_server.dev_mode_enabled', False, raising=False)
|
|
|
|
resp = app_test_client.get(
|
|
'/api/spotify/search_tracks?track=Track&artist=Real+Artist'
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
ids = [t['id'] for t in body['tracks']]
|
|
assert ids[0] == 'real-1'
|