# Background User reported (#534) that the import-modal "Search for Match" dialog returned irrelevant results when Deezer was the metadata source. Searching `Dirty White Boy` + `Foreigner` returned 5+ karaoke / "originally performed by" / "in the style of" / "re-recorded" / tribute-band results ranked above the actual Foreigner studio cut from Head Games. User had to scroll past the junk every time, or fall back to iTunes search which is much slower. # Root cause — two layers 1. **Endpoint joined `track + artist` into free-text query.** `/api/deezer/search_tracks` was passing `q=Dirty White Boy Foreigner` to Deezer's `/search/track` API. Deezer fuzzy-matches that string across title / lyrics / artist / album / contributors and orders by global popularity — anything that appears across many compilations outranks the canonical recording. 2. **No local rerank.** None of the search-modal endpoints applied any post-filtering. Deezer's API order shipped straight to the user. # Fix — same architectural shape Cin would build ## Layer 1: field-scoped query at the client boundary `core/deezer_client.py::search_tracks()` now accepts optional `track`, `artist`, `album` kwargs. When provided, builds Deezer's advanced search syntax: `q=track:"X" artist:"Y" album:"Z"`. Massive relevance improvement because each term matches the right field instead of fuzzy-matching everywhere. Backward compat preserved: legacy free-text `query=` callers still work unchanged. Field-scoped path takes precedence when both are provided. Empty input fast-fails without an API call. Embedded double-quotes stripped (Deezer's syntax has no escape mechanism). ## Layer 2: provider-neutral relevance reranker New `core/metadata/relevance.py` module — pure-function rerank over the canonical `Track` dataclass. Composable scoring: - **Cover/karaoke patterns** (multiplier 0.05, effectively buries): matches "karaoke", "originally performed by", "in the style of", "made famous by", "tribute", "vocal version", "backing track", "cover version", "re-recorded", "cover by", etc. across title, album, AND artist fields. Catches the screenshot's exact junk: artist credits like "Pop Music Workshop" / "The Karaoke Channel" / "Foreigner Tribute Band". - **Variant tags** (multiplier 0.4): live / acoustic / demo / instrumental / remix / radio edit / club mix etc. — softer penalty since the user MAY want them. Skipped entirely when the expected_title contains the same tag (so searching "Track (Live)" still ranks Live versions first). - **Exact artist boost** (multiplier 1.5): primary artist exactly matches expected_artist after normalisation. Single strongest signal for "this is the canonical recording". - **Title + artist similarity** via SequenceMatcher (parentheticals + punctuation stripped before comparison). - **Album-type weighting**: album=1.0 > single/ep=0.85 > compilation=0.7. Compilations are more likely tribute / karaoke repackages. Each component is a standalone function so tests pin them individually without standing up the full pipeline. ## Wired at three search-modal endpoints - `/api/deezer/search_tracks` — uses both layers (field-scoped query + rerank). - `/api/itunes/search_tracks` — uses rerank only (iTunes API has no advanced-syntax search, but karaoke / cover variants still leak through and need the local penalty). - `/api/spotify/search_tracks` — already builds field-scoped `track:X artist:Y` query; rerank added as the consistency safety net so all three sources behave the same from the user's perspective. Other Deezer call sites (matching engine, watchlist scanner, auto-import single-track ID) deliberately not touched in this PR — they have their own elaborate scoring pipelines tuned to their specific contexts and aren't surfacing the user-reported issue. Per Cin: "don't refactor beyond what the task requires." # Tests 71 new tests across 3 files: - `tests/metadata/test_relevance.py` (50 tests) — every scoring component pinned individually + the issue #534 screenshot reproduced as a regression test (real Foreigner cut wins after rerank, karaoke variants drop to bottom). - `tests/metadata/test_deezer_search_query.py` (14 tests) — advanced-syntax query construction, field-scoped wiring at the client boundary, free-text path unchanged, kwargs win when ambiguous, limit clamping, cache key consistency. - `tests/imports/test_search_match_endpoints.py` (7 tests) — end-to-end through Flask test client: Deezer endpoint passes kwargs not joined query; karaoke buried at bottom for all three sources; legacy query param still works without rerank. # Verification - 2441 full suite passes (+71 from baseline 2370) - 0 failures (the prior watchdog flake fix held) - Ruff clean across all changed files - JS parses clean (`node -c webui/static/helper.js`) # Architectural standards followed - **Logic at the right boundary.** Query construction lives in the client (every caller benefits from one change). Rerank lives in a neutral module (`core/metadata/relevance.py`) over the canonical `Track` dataclass — works for any source, not Deezer- specific. - **Explicit > implicit.** Every scoring rule has its own named function. Pattern tables are module-level constants tests can introspect. - **Scope discipline.** Audited every Deezer search call site; fixed the user-reported one + the consistent siblings. Did NOT speculatively normalise every Deezer call across the codebase. - **Backward compat.** Free-text `query=` callers untouched. Kwargs added to existing client method signature with safe defaults. - **Tests pin contract at correct boundary.** Pure-function rerank tests don't mock anything; client-query tests stub at `_api_get`; endpoint tests run through the real Flask app.
210 lines
9.6 KiB
Python
210 lines
9.6 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_passes_track_and_artist_as_kwargs(self, app_test_client, fake_track):
|
|
"""Endpoint must call client.search_tracks(track=..., artist=...)
|
|
— NOT join into a single positional query. Field-scoped path
|
|
is what triggers Deezer's advanced search syntax."""
|
|
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
|
|
# Field-scoped kwargs reach the client
|
|
call = fake_client.search_tracks.call_args
|
|
assert call.kwargs.get('track') == 'Dirty White Boy'
|
|
assert call.kwargs.get('artist') == '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'
|