# 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.
336 lines
11 KiB
Python
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
|
|
]
|