soulsync/core/metadata/relevance.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

336 lines
11 KiB
Python

"""Local relevance re-ranking for metadata-source search results.
Background
----------
Some metadata sources (Deezer notably) return search results in a
relevance order that puts karaoke covers, "originally performed by",
re-recorded versions, tribute compilations, and Vocal/Backing-Track
variants ABOVE the actual studio recording the user is looking for.
Their global popularity ordering means anything that appears across
many compilations outranks the canonical track. Issue #534 is the
canonical example: searching `Dirty White Boy` + `Foreigner` returned
five karaoke / cover variants before the real Foreigner studio cut.
This module is a provider-neutral helper. Given a list of typed
``Track`` results plus an expected title + artist, it re-ranks by
local heuristics that the source's own ranking ignores:
- Hard penalty for known cover/karaoke/tribute patterns (title OR
album OR artist field). These rarely belong in import / match
results when the user typed the original artist.
- Soft penalty for variant types (Live, Acoustic, Remix, Demo,
Instrumental) UNLESS the user's expected title also contains the
variant tag (so "Track (Live)" search matches Live recordings).
- Boost for exact artist match — the strongest signal that this is
the canonical recording.
- Title similarity via SequenceMatcher on normalised strings (drop
parentheticals + punctuation before comparison).
- Album-type weight: album > compilation > single (compilations are
more likely to be tributes / "best of" repackages).
Pure-function design over the canonical ``Track`` dataclass —
no Deezer-specific assumptions, applies to iTunes / Spotify /
Hydrabase results equally well. Each scoring component is its own
small function so tests can pin them independently.
Usage
-----
>>> from core.metadata.relevance import rerank_tracks
>>> tracks = client.search_tracks(query)
>>> ranked = rerank_tracks(tracks, expected_title='Dirty White Boy', expected_artist='Foreigner')
>>> # ranked[0] is now the most relevant; karaoke variants drop to bottom
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from typing import List, Optional, Sequence
from core.metadata.types import Track
# ---------------------------------------------------------------------------
# Pattern tables — public so tests can introspect, callers can extend
# ---------------------------------------------------------------------------
# Title / album / artist substrings that strongly indicate a cover,
# karaoke, tribute, or "originally performed by" compilation. Multiplier
# applied to the final score when matched. 0.05 effectively buries these
# unless nothing else matches.
COVER_KARAOKE_PATTERNS = (
'karaoke',
'originally performed by',
'in the style of',
'made famous by',
'tribute',
'vocal version', # karaoke "vocal version" backing tracks
'backing track',
'cover version',
're-recorded', # artist re-recordings (Taylor's Version notwithstanding)
're-record',
'rerecorded',
'cover by',
'as performed by',
'workout mix', # gym-music compilations
'study music',
'music for', # "Music for Studying", "Music for Sleep" etc
)
COVER_KARAOKE_PENALTY = 0.05 # Multiplicative; effectively bury
# Variant tags — softer penalty since the user MAY want them. Skipped
# when the user's expected_title also contains the same tag (so
# "Track Name (Live)" search matches the Live version cleanly).
VARIANT_TAG_PATTERNS = (
'live',
'acoustic',
'demo',
'instrumental',
'remix',
'edit',
'extended',
'radio edit',
'club mix',
'a cappella',
'acapella',
)
VARIANT_TAG_PENALTY = 0.4
# Strong boost when the source's artist field exactly matches the
# user's expected artist (case-insensitive, normalised). The single
# strongest signal that this is the canonical recording.
EXACT_ARTIST_BOOST = 1.5
# Album-type weights. Compilations are more likely to be tributes /
# karaoke repackages; albums are most likely to be the canonical
# studio source.
ALBUM_TYPE_WEIGHT = {
'album': 1.0,
'single': 0.85,
'ep': 0.85,
'compilation': 0.7,
}
DEFAULT_ALBUM_TYPE_WEIGHT = 0.85
# ---------------------------------------------------------------------------
# Normalisation
# ---------------------------------------------------------------------------
_PARENTHETICAL_RE = re.compile(r'[\(\[].*?[\)\]]')
_PUNCT_RE = re.compile(r'[^\w\s]')
def _normalise(text: str) -> str:
"""Lowercase, strip parentheticals + punctuation, collapse spaces.
Used for similarity scoring AND for variant-tag detection (since
we want to know if the user typed the variant tag inside their
own search input)."""
if not text:
return ''
t = text.lower().strip()
t = _PARENTHETICAL_RE.sub('', t)
t = _PUNCT_RE.sub('', t)
return ' '.join(t.split())
def _contains_pattern(haystack: str, patterns: Sequence[str]) -> bool:
"""Case-insensitive substring match across patterns. Read raw
`haystack` (NOT the parenthetical-stripped version) — patterns
like "karaoke" most often live INSIDE the parentheticals on
Deezer's titles."""
if not haystack:
return False
lowered = haystack.lower()
return any(p in lowered for p in patterns)
# ---------------------------------------------------------------------------
# Scoring components
# ---------------------------------------------------------------------------
def title_similarity(track: Track, expected_title: str) -> float:
"""Normalised SequenceMatcher ratio against the expected title."""
if not expected_title:
return 0.0
return SequenceMatcher(
None,
_normalise(track.name),
_normalise(expected_title),
).ratio()
def primary_artist(track: Track) -> str:
"""First entry from track.artists — that's the lead/primary
credit. Empty when the track has no artist info."""
if not track.artists:
return ''
first = track.artists[0]
if isinstance(first, dict):
# Some sources still surface raw dicts during migration; fall
# back to .get() rather than assume the dataclass is fully
# normalised.
return str(first.get('name', '') or '')
return str(first)
def artist_similarity(track: Track, expected_artist: str) -> float:
"""Normalised SequenceMatcher ratio against the expected artist."""
if not expected_artist:
return 0.0
return SequenceMatcher(
None,
_normalise(primary_artist(track)),
_normalise(expected_artist),
).ratio()
def has_exact_artist(track: Track, expected_artist: str) -> bool:
"""True when the primary artist matches expected_artist after
normalisation. Strict equality on the normalised form (so
"Foreigner" matches "Foreigner" but not "Foreigner Tribute Band")."""
if not expected_artist:
return False
return _normalise(primary_artist(track)) == _normalise(expected_artist)
def has_cover_pattern(track: Track) -> bool:
"""Any cover/karaoke/tribute pattern in the track title, album
title, or artist credits."""
if _contains_pattern(track.name, COVER_KARAOKE_PATTERNS):
return True
if _contains_pattern(track.album, COVER_KARAOKE_PATTERNS):
return True
if _contains_pattern(primary_artist(track), COVER_KARAOKE_PATTERNS):
return True
return False
def has_variant_tag(track: Track) -> bool:
"""Track title contains a variant-version tag (Live, Acoustic,
Remix, Demo, Instrumental, etc.). Album field is intentionally
NOT checked — albums named "MTV Unplugged" shouldn't penalise
every track on them."""
return _contains_pattern(track.name, VARIANT_TAG_PATTERNS)
def album_type_weight(track: Track) -> float:
"""Weight from track.album_type. Compilations ranked lower since
they're frequently tribute / karaoke repackages."""
if not track.album_type:
return DEFAULT_ALBUM_TYPE_WEIGHT
return ALBUM_TYPE_WEIGHT.get(track.album_type.lower(), DEFAULT_ALBUM_TYPE_WEIGHT)
# ---------------------------------------------------------------------------
# Combined score
# ---------------------------------------------------------------------------
def score_track(
track: Track,
*,
expected_title: str,
expected_artist: str,
) -> float:
"""Combined relevance score for a single track. Higher = more
relevant. Roughly 0.0 - 2.5 in practice (boosts can push above
1.0; penalties can push below 0.1).
Composition:
1. Base = title_sim * 0.6 + artist_sim * 0.4
2. Multiply by album_type_weight
3. If exact artist match: multiply by EXACT_ARTIST_BOOST
4. If cover/karaoke pattern: multiply by COVER_KARAOKE_PENALTY
(effectively buries unless nothing else matched)
5. If variant tag (Live, Remix, etc.) AND user did NOT type
a variant tag in their input: multiply by VARIANT_TAG_PENALTY
Each rule is its own component above so tests can pin them
individually without standing up the full pipeline.
"""
title_sim = title_similarity(track, expected_title)
artist_sim = artist_similarity(track, expected_artist)
score = title_sim * 0.6 + artist_sim * 0.4
score *= album_type_weight(track)
if has_exact_artist(track, expected_artist):
score *= EXACT_ARTIST_BOOST
if has_cover_pattern(track):
score *= COVER_KARAOKE_PENALTY
# Variant tag penalty — only when the user didn't ask for a
# variant. Their input "Track (Live)" should rank Live versions
# higher, not lower.
user_wanted_variant = _contains_pattern(expected_title, VARIANT_TAG_PATTERNS)
if has_variant_tag(track) and not user_wanted_variant:
score *= VARIANT_TAG_PENALTY
return score
def rerank_tracks(
tracks: List[Track],
*,
expected_title: str,
expected_artist: str,
) -> List[Track]:
"""Return a copy of ``tracks`` sorted by descending relevance
score against the expected title + artist.
Caller's input list is left untouched. Stable sort preserves the
source's original ordering as a tiebreaker (which is the right
fallback when two candidates score identically — the source's
popularity signal is still useful as a tiebreak).
No-op when both ``expected_title`` and ``expected_artist`` are
empty (no signal to rank against — return input order)."""
if not expected_title and not expected_artist:
return list(tracks)
scored = [
(score_track(t, expected_title=expected_title, expected_artist=expected_artist), idx, t)
for idx, t in enumerate(tracks)
]
# Sort by score desc; idx asc as tiebreaker preserves stable order.
scored.sort(key=lambda x: (-x[0], x[1]))
return [t for _score, _idx, t in scored]
def filter_and_rerank(
tracks: List[Track],
*,
expected_title: str,
expected_artist: str,
min_score: Optional[float] = None,
) -> List[Track]:
"""Convenience: rerank then optionally drop everything below a
score floor. Useful when callers want to hide low-confidence
matches entirely instead of demoting them.
Returns reranked-only list when ``min_score`` is None — same as
``rerank_tracks``."""
ranked = rerank_tracks(
tracks,
expected_title=expected_title,
expected_artist=expected_artist,
)
if min_score is None:
return ranked
return [
t for t in ranked
if score_track(t, expected_title=expected_title, expected_artist=expected_artist) >= min_score
]