From fb5aaf55af69b50abf39d2169d06d444d86a3041 Mon Sep 17 00:00:00 2001 From: Vincent Escoffier Date: Fri, 3 Apr 2026 16:13:39 +0200 Subject: [PATCH 1/2] fix: pick best MusicBrainz match instead of blindly taking first result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artist metadata endpoint /api/metadata/artist/Mestís returned data for "Mest" (punk rock) instead of "Mestís" (Javier Reyes, progressive). Root cause: MusicBrainz search used limit=1 and blindly took the first fuzzy match. "Mest" ranked higher than "Mestís" in MB's fuzzy scoring. Fix: - Fetch 5 results from MusicBrainz instead of 1 - Pick the best match using _pick_best_mb_artist(): exact normalized match first, then starts-with, then fall back to MB score - Only overwrite the query name with MB name when it's a normalized match (accent/casing differences), not a completely different artist - Guard the TheAudioDB canonical-name fallback with the same check --- api/services/metadata_service.py | 44 +++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 58d7cc6..30d4c7f 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -46,6 +46,34 @@ def _set_image_cached(key: str, url: Optional[str]): _image_cache[key] = {"data": url or "", "ts": time.time()} +def _normalize_for_match(name: str) -> str: + """Normalize artist name for comparison: strip accents, lowercase, collapse whitespace.""" + import unicodedata + n = unicodedata.normalize('NFD', name) + n = ''.join(c for c in n if unicodedata.category(c) != 'Mn') + n = n.lower().strip() + n = ' '.join(n.split()) + return n + + +def _pick_best_mb_artist(artists: list, query: str) -> Optional[dict]: + """Pick the MusicBrainz result that best matches the query name.""" + nq = _normalize_for_match(query) + # First pass: exact normalized match + for a in artists: + if _normalize_for_match(a.get("name", "")) == nq: + return a + # Second pass: starts-with match + for a in artists: + na = _normalize_for_match(a.get("name", "")) + if na.startswith(nq) or nq.startswith(na): + return a + # Third pass: MusicBrainz score-based (first result is highest score) + if artists: + return artists[0] + return None + + def search_musicbrainz_artist(artist_name: str) -> Optional[dict]: """Search MusicBrainz for an artist and return MBID + basic info.""" cache_key = f"mb_artist:{artist_name.lower()}" @@ -56,15 +84,15 @@ def search_musicbrainz_artist(artist_name: str) -> Optional[dict]: try: resp = requests.get( "https://musicbrainz.org/ws/2/artist/", - params={"query": artist_name, "fmt": "json", "limit": 1}, + params={"query": artist_name, "fmt": "json", "limit": 5}, headers={"User-Agent": USER_AGENT}, timeout=5, ) if resp.status_code == 200: data = resp.json() artists = data.get("artists", []) - if artists: - artist = artists[0] + artist = _pick_best_mb_artist(artists, artist_name) + if artist: result = { "mbid": artist.get("id"), "name": artist.get("name"), @@ -144,11 +172,12 @@ def get_artist_image(artist_name: str) -> Optional[str]: _set_image_cached(cache_key, url) return url - # 4. MusicBrainz fuzzy search to get canonical name, then retry TheAudioDB + # 4. MusicBrainz search to get canonical name, then retry TheAudioDB mb = search_musicbrainz_artist(cleaned) if mb: canonical = mb.get("name", "") - if canonical and canonical.lower() != cleaned.lower(): + # Only use canonical name if it's a normalized match (e.g. accent differences) + if canonical and canonical.lower() != cleaned.lower() and _normalize_for_match(canonical) == _normalize_for_match(cleaned): url = _audiodb_image_lookup(canonical) if url: _set_image_cached(cache_key, url) @@ -178,10 +207,13 @@ def get_artist_info(artist_name: str) -> dict: # MusicBrainz for metadata mb = search_musicbrainz_artist(artist_name) if mb: + mb_name = mb.get("name", "") + # Only use MB name if it's a close match to the query (same name, different casing/accents) + if mb_name and _normalize_for_match(mb_name) == _normalize_for_match(artist_name): + result["name"] = mb_name result["mbid"] = mb.get("mbid") result["country"] = mb.get("country") result["tags"] = mb.get("tags", []) - result["name"] = mb.get("name", artist_name) # TheAudioDB for image + bio try: From 279dc366cbe0b62bf821b97d0661c200b3487b0c Mon Sep 17 00:00:00 2001 From: Vincent Escoffier Date: Fri, 3 Apr 2026 16:26:31 +0200 Subject: [PATCH 2/2] fix: tighten fuzzy search matching and add artist-aware scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching "mestis" returned garbage like "Steel Meets Steel", "Chopis Centis", "Les Meutes" because: 1. fuzzy_match() used loose substring check (qw in tw) and Levenshtein distance=2 for 6+ char words. "mestis" matched "mystic" (dist=2), "meets" (dist=2), etc. Now: - Substring only matches if query covers 70%+ of the target word - Levenshtein threshold=1 for words under 8 chars - Length difference capped at 1 char 2. Scoring had no artist-name awareness. A tab by artist "Mestís" scored the same as random fuzzy matches. Now: - Exact artist match: +300 - Artist word overlap with query: +200 - Uses accent-stripped comparison for both --- api/services/search_service.py | 52 ++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/api/services/search_service.py b/api/services/search_service.py index 1de604d..422d6a8 100644 --- a/api/services/search_service.py +++ b/api/services/search_service.py @@ -37,17 +37,24 @@ def fuzzy_match(query: str, target: str, max_distance: int = 2) -> bool: for qw in query_words: matched = False for tw in target_words: - # Exact substring match - if qw in tw: + # Exact word match + if qw == tw: matched = True break - # Levenshtein on short words (avoid expensive computation on long strings) + # Substring match only if query word is most of the target word + # "mestis" in "mestis123" is ok, but "est" in "mestis" is not + if qw in tw and len(qw) >= len(tw) * 0.7: + matched = True + break + # Levenshtein on similar-length words if len(qw) >= 3 and len(tw) >= 3: - dist = levenshtein_distance(qw, tw) - threshold = 1 if len(qw) <= 4 else max_distance - if dist <= threshold: - matched = True - break + if abs(len(qw) - len(tw)) <= 1: + dist = levenshtein_distance(qw, tw) + # Strict: only 1 edit for words under 8 chars + threshold = 1 if len(qw) < 8 else max_distance + if dist <= threshold: + matched = True + break if not matched: return False return True @@ -170,28 +177,43 @@ def search_tabs( # Score results query_lower = query.lower().strip() + import unicodedata + def _strip_accents(s: str) -> str: + return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') + query_normalized = _strip_accents(query_lower) + scored_results = [] for tab in results: score = 0 text = f"{tab.title} {tab.artist or ''} {tab.album or ''}".lower() - - # Exact full phrase match bonus - if query_lower in text: - score += 100 + artist_lower = (tab.artist or '').lower() + artist_normalized = _strip_accents(artist_lower) + + # Artist name matches query (strongest signal) + if artist_normalized == query_normalized: + score += 300 + else: + # Word-boundary check: "clapton" matches "eric clapton" but "mest" doesn't match "mestis" + artist_words = set(artist_normalized.split()) + query_words_set = set(query_normalized.split()) + if query_words_set & artist_words: + score += 200 # Exact title match - if tab.title.lower() == query_lower: + if tab.title.lower() == query_lower or _strip_accents(tab.title.lower()) == query_normalized: score += 200 + # Exact full phrase match bonus + if query_lower in text or query_normalized in _strip_accents(text): + score += 100 + # Naive words scoring for word in parsed.naive_words: if word in text: score += 10 - # Boost longer word matches, penalize very short partial matches if len(word) >= 4: score += 5 elif len(word) >= 3: - # Fuzzy match gives lower score words_in_text = text.split() for tw in words_in_text: if len(tw) >= 3 and levenshtein_distance(word, tw) <= 2: