Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions api/services/metadata_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
Expand All @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
52 changes: 37 additions & 15 deletions api/services/search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down