soulsync/tests/imports/test_search_match_endpoints.py
Broque Thomas 1cc37081a6 Fix Deezer search relevance — issue #534
# 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.
2026-05-10 08:53:42 -07:00

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'