Artist-detail discography from MusicBrainz fetched releases via the artist lookup (`/artist/<mbid>?inc=release-groups`), which MusicBrainz hard-caps at 25 embedded release-groups and which ignores the `limit` param entirely. Prolific artists had ~85% of their catalogue silently dropped — Kendrick Lamar has 167 release-groups on the site but only the first 25 ever reached SoulSync. Reported by Sokhi: "a lot of albums are missing when searching vs what's showing on the site." Switch `get_artist_albums` to walk the paginated browse endpoint (`/release-group?artist=<mbid>`, offset loop) — the same pattern the basic-search path already uses — fetching the full catalogue up to the caller's limit. No type filter and no studio-only filter here: the artist-detail page wants every primary/secondary type so its tabs mirror musicbrainz.org. Verified live: now returns all 167 for Kendrick. Adds 7 tests covering pagination past the cap, offset advance, short-page stop, limit cap, cross-page dedup, type->bucket mapping, and a regression pin asserting the capped inc=release-groups lookup is no longer the discography source.
1190 lines
54 KiB
Python
1190 lines
54 KiB
Python
"""MusicBrainz Search Adapter — provides enhanced search tab integration.
|
||
|
||
Wraps the existing MusicBrainzClient with search methods that return the
|
||
same Track/Artist/Album dataclass format used by Deezer/iTunes/Discogs,
|
||
enabling MusicBrainz as a search tab in enhanced and global search.
|
||
Album art is fetched from Cover Art Archive (free, linked by release MBID).
|
||
"""
|
||
|
||
import threading
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from utils.logging_config import get_logger
|
||
|
||
logger = get_logger("musicbrainz_search")
|
||
|
||
COVER_ART_ARCHIVE_URL = "https://coverartarchive.org"
|
||
|
||
|
||
@dataclass
|
||
class Track:
|
||
id: str
|
||
name: str
|
||
artists: List[str]
|
||
album: str
|
||
duration_ms: int
|
||
popularity: int
|
||
preview_url: Optional[str] = None
|
||
external_urls: Optional[Dict[str, str]] = None
|
||
image_url: Optional[str] = None
|
||
release_date: Optional[str] = None
|
||
track_number: Optional[int] = None
|
||
disc_number: Optional[int] = None
|
||
album_type: Optional[str] = None
|
||
total_tracks: Optional[int] = None
|
||
album_id: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class Artist:
|
||
id: str
|
||
name: str
|
||
popularity: int
|
||
genres: List[str]
|
||
followers: int
|
||
image_url: Optional[str] = None
|
||
external_urls: Optional[Dict[str, str]] = None
|
||
|
||
|
||
@dataclass
|
||
class Album:
|
||
id: str
|
||
name: str
|
||
artists: List[str]
|
||
release_date: str
|
||
total_tracks: int
|
||
album_type: str
|
||
image_url: Optional[str] = None
|
||
external_urls: Optional[Dict[str, str]] = None
|
||
format: Optional[str] = None
|
||
country: Optional[str] = None
|
||
status: Optional[str] = None
|
||
label: Optional[str] = None
|
||
disambiguation: Optional[str] = None
|
||
release_group_id: Optional[str] = None
|
||
|
||
|
||
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
|
||
"""Build a Cover Art Archive URL without hitting the network.
|
||
|
||
CAA URLs are deterministic from the MBID: the endpoint either 307-redirects
|
||
to the image or returns 404. Previously we fired `requests.head(timeout=3)`
|
||
per result during search — 10 results × 3s worst-case = up to 30s of
|
||
blocking HEAD calls before a search returned. The frontend's <img> tag
|
||
handles the 404 case via onerror fallback, so the HEAD round-trip was
|
||
pure overhead.
|
||
|
||
`scope` is 'release' (most specific) or 'release-group' (covers all
|
||
editions — better hit rate).
|
||
"""
|
||
if not mbid:
|
||
return None
|
||
if scope not in ('release', 'release-group'):
|
||
scope = 'release'
|
||
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front-250"
|
||
|
||
|
||
def _extract_artist_credit(artist_credit) -> List[str]:
|
||
"""Extract artist names from MusicBrainz artist-credit array."""
|
||
if not artist_credit:
|
||
return []
|
||
names = []
|
||
for credit in artist_credit:
|
||
if isinstance(credit, dict) and 'artist' in credit:
|
||
names.append(credit['artist'].get('name', ''))
|
||
elif isinstance(credit, dict) and 'name' in credit:
|
||
names.append(credit['name'])
|
||
return [n for n in names if n]
|
||
|
||
|
||
def _extract_title_hint(query: str, artist_name: str) -> Optional[str]:
|
||
"""If `query` starts with `artist_name` followed by more words, return
|
||
the trailing portion. Used to pick out the album/track title the user
|
||
typed after the artist name (e.g. "The Beatles Abbey Road" → "Abbey
|
||
Road"). Returns None when the query is just the artist name.
|
||
|
||
Case-insensitive prefix match on whitespace-normalized versions of
|
||
both strings, so "the beatles abbey road" → "abbey road" and
|
||
"The Beatles" → None.
|
||
"""
|
||
if not query or not artist_name:
|
||
return None
|
||
q_norm = ' '.join(query.split()).lower()
|
||
a_norm = ' '.join(artist_name.split()).lower()
|
||
if q_norm == a_norm:
|
||
return None
|
||
# Require a word boundary between the artist name and the trailing bit.
|
||
if q_norm.startswith(a_norm + ' '):
|
||
return query[len(artist_name):].strip() or None
|
||
return None
|
||
|
||
|
||
# Thin module-level alias retained so callers inside this file keep
|
||
# working without touching every call site. The canonical implementation
|
||
# (including the 'other' / 'broadcast' handling that fixes issue #650)
|
||
# lives in `core/metadata/release_type.py` so every provider's `raw →
|
||
# Album` projection shares one mapper.
|
||
from core.metadata.release_type import map_release_group_type as _map_release_type
|
||
|
||
|
||
class MusicBrainzSearchClient:
|
||
"""Search adapter for MusicBrainz — compatible with enhanced search tab system."""
|
||
|
||
def __init__(self):
|
||
from core.musicbrainz_client import MusicBrainzClient
|
||
# Client defaults to the project URL as its User-Agent contact,
|
||
# which is what MusicBrainz wants. Version stays generic ("2") —
|
||
# the exact UI minor version would add noise to every request.
|
||
self._client = MusicBrainzClient("SoulSync", "2")
|
||
# Per-instance cache for "top artist MBID for this query". The
|
||
# backend fires artists/albums/tracks searches in parallel against
|
||
# one client instance, and albums+tracks both need the same artist
|
||
# lookup. Without this cache, we'd fire 3 identical artist-search
|
||
# HTTP calls (each serialized by the 1-rps rate limit = 3 wasted
|
||
# seconds). The _Sentinel marks "we already looked and found
|
||
# nothing" to prevent repeat no-hit lookups.
|
||
self._artist_mbid_cache: Dict[str, Optional[Dict[str, Any]]] = {}
|
||
self._artist_mbid_lock = threading.Lock()
|
||
|
||
def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
|
||
"""Build a Cover Art Archive URL for a release / release-group MBID.
|
||
|
||
Prefers release-group scope when provided — better hit rate because
|
||
it covers all editions of the same album. No network call; the
|
||
frontend's <img onerror> fallback handles 404s.
|
||
"""
|
||
preferred = release_group_mbid or release_mbid
|
||
if not preferred:
|
||
return None
|
||
scope = 'release-group' if release_group_mbid else 'release'
|
||
return _cover_art_url(preferred, scope=scope)
|
||
|
||
# Score threshold for user-facing search results. MusicBrainz returns a
|
||
# Lucene score 0-100 on every match; exact name/alias hits score 100,
|
||
# partial/typo matches trend lower, and tribute bands / random
|
||
# lookalikes score 40-65. 80 is the cutoff that keeps the true artist
|
||
# and close variants while dropping unrelated noise.
|
||
_MIN_SCORE = 80
|
||
|
||
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
|
||
"""Search MusicBrainz for artists by name.
|
||
|
||
Uses a bare Lucene query (no field prefix) so MusicBrainz searches
|
||
the alias, artist, AND sortname indexes together — much better
|
||
recall than strict `artist:"..."` phrase matching. Results are
|
||
filtered by score (>= 80) to drop tribute bands and unrelated
|
||
lookalikes.
|
||
"""
|
||
try:
|
||
# Fetch extra so dedup below has enough to pick from. For
|
||
# common names (Michael Jackson, John Williams, etc.) MB returns
|
||
# many same-named people; without a larger pool, capping at
|
||
# `limit` before dedup can leave us with fewer results than
|
||
# requested.
|
||
raw = self._client.search_artist(query, limit=max(limit * 3, 10), strict=False)
|
||
|
||
# Dedupe by normalized name. MusicBrainz has many different
|
||
# people with the same canonical name (7 entries for "Michael
|
||
# Jackson" — the singer + poet + photographer + didgeridoo
|
||
# player + ...), all scoring 80+ on exact-name match. Rendered
|
||
# as identical cards since the fallback image lookup hits the
|
||
# same fallback-source result for each. Keep the highest-
|
||
# scoring entry per normalized name so the user sees one card
|
||
# per distinct artist.
|
||
seen = {}
|
||
for a in raw:
|
||
score = a.get('score', 0) or 0
|
||
if score < self._MIN_SCORE:
|
||
continue
|
||
mbid = a.get('id', '')
|
||
name = a.get('name', '')
|
||
if not mbid or not name:
|
||
continue
|
||
key = name.lower().strip()
|
||
if key not in seen or (seen[key].get('score', 0) or 0) < score:
|
||
seen[key] = a
|
||
|
||
# Sort the survivors score-descending and cap at the caller's
|
||
# limit. `seen` only holds top-per-name, so ordering is stable.
|
||
top = sorted(seen.values(), key=lambda r: -(r.get('score', 0) or 0))[:limit]
|
||
|
||
artists = []
|
||
for a in top:
|
||
mbid = a.get('id', '')
|
||
name = a.get('name', '')
|
||
|
||
# Genres from MB tags (user-applied categorical labels). Each
|
||
# tag has {name, count}; keep the top-weighted ones.
|
||
tags = a.get('tags', []) or []
|
||
genres = [t.get('name') for t in tags if t.get('name')][:5]
|
||
|
||
external_urls = {
|
||
'musicbrainz': f'https://musicbrainz.org/artist/{mbid}'
|
||
}
|
||
|
||
artists.append(Artist(
|
||
id=mbid,
|
||
name=name,
|
||
popularity=a.get('score', 0) or 0, # Reuse score as popularity (0-100)
|
||
genres=genres,
|
||
followers=0, # MusicBrainz doesn't track followers
|
||
image_url=None, # MB doesn't store artist images directly
|
||
external_urls=external_urls,
|
||
))
|
||
return artists
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz artist search failed: {e}")
|
||
return []
|
||
|
||
def _split_structured_query(self, query: str):
|
||
"""Split 'Artist - Title' / 'Artist – Title' / 'Artist — Title' if
|
||
a separator is present. Returns (artist_name, title) or (None, query)."""
|
||
for sep in [' - ', ' – ', ' — ']:
|
||
if sep in query:
|
||
parts = query.split(sep, 1)
|
||
return parts[0].strip(), parts[1].strip()
|
||
return None, query
|
||
|
||
def _resolve_top_artist(self, query: str) -> Optional[Dict[str, Any]]:
|
||
"""Return the top-scoring artist for a bare-name query, or None if
|
||
nothing scores above threshold. Cached per instance so parallel
|
||
album/track searches don't each refetch."""
|
||
if not query:
|
||
return None
|
||
key = query.strip().lower()
|
||
with self._artist_mbid_lock:
|
||
if key in self._artist_mbid_cache:
|
||
return self._artist_mbid_cache[key]
|
||
# Do the HTTP call OUTSIDE the lock so other threads can still
|
||
# check the cache while we wait on the network.
|
||
raw = self._client.search_artist(query, limit=1, strict=False)
|
||
top = None
|
||
if raw and (raw[0].get('score', 0) or 0) >= self._MIN_SCORE:
|
||
top = raw[0]
|
||
with self._artist_mbid_lock:
|
||
self._artist_mbid_cache[key] = top
|
||
return top
|
||
|
||
# Secondary-type tags on MB release-groups that indicate NOT a studio
|
||
# release. Used by both the album browse (filter out) and the track
|
||
# browse (prefer studio release for album context).
|
||
_NON_STUDIO_SECONDARY_TYPES = {
|
||
'Live', 'Compilation', 'Soundtrack', 'Remix', 'Demo',
|
||
'Mixtape/Street', 'Interview', 'Audiobook', 'Audio drama',
|
||
}
|
||
|
||
def _release_preference_key(self, rel: Dict[str, Any]):
|
||
"""Sort key: studio releases first, then by date ASC.
|
||
|
||
Recordings in MB often have 10+ releases (studio album, live, best-of,
|
||
reissues, anniversary editions). The first one in the API response is
|
||
arbitrary — it's often a recent live bootleg because MB users add new
|
||
live recordings all the time. Re-sorting before `_recording_to_track`
|
||
reads the first release means tracks show their canonical studio
|
||
album, not a random live compilation.
|
||
"""
|
||
rg = rel.get('release-group') or {}
|
||
secs = set(rg.get('secondary-types') or [])
|
||
is_studio = 0 if not (secs & self._NON_STUDIO_SECONDARY_TYPES) else 1
|
||
date = (rel.get('date') or '')[:4]
|
||
year = int(date) if date.isdigit() else 9999
|
||
return (is_studio, year)
|
||
|
||
def _has_studio_release(self, recording: Dict[str, Any]) -> bool:
|
||
"""True when at least one of the recording's releases is on a
|
||
release-group with no non-studio secondary type."""
|
||
for rel in (recording.get('releases') or []):
|
||
rg = rel.get('release-group') or {}
|
||
secs = set(rg.get('secondary-types') or [])
|
||
if not (secs & self._NON_STUDIO_SECONDARY_TYPES):
|
||
return True
|
||
return False
|
||
|
||
def _release_group_to_album(self, rg: Dict[str, Any], artist_name: str) -> Album:
|
||
"""Project a MusicBrainz release-group into our Album dataclass."""
|
||
rg_mbid = rg.get('id', '')
|
||
title = rg.get('title', '') or ''
|
||
primary_type = rg.get('primary-type', '') or ''
|
||
secondary_types = rg.get('secondary-types', []) or []
|
||
album_type = _map_release_type(primary_type, secondary_types)
|
||
release_date = rg.get('first-release-date', '') or ''
|
||
# Release-group browse doesn't link directly to a single release,
|
||
# so we can't get per-release track counts cheaply. Leave 0 — the
|
||
# frontend treats it as "unknown" gracefully.
|
||
image_url = self._cached_art(rg_mbid, rg_mbid)
|
||
return Album(
|
||
id=rg_mbid,
|
||
name=title,
|
||
artists=[artist_name] if artist_name else ['Unknown Artist'],
|
||
release_date=release_date,
|
||
total_tracks=0,
|
||
album_type=album_type,
|
||
image_url=image_url,
|
||
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {},
|
||
disambiguation=rg.get('disambiguation') or None,
|
||
release_group_id=rg_mbid or None,
|
||
)
|
||
|
||
def _release_total_tracks(self, release: Dict[str, Any]) -> int:
|
||
total_tracks = 0
|
||
for medium in release.get('media', []) or []:
|
||
try:
|
||
total_tracks += int(medium.get('track-count') or 0)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return total_tracks
|
||
|
||
def _release_formats(self, release: Dict[str, Any]) -> str:
|
||
formats = []
|
||
for medium in release.get('media', []) or []:
|
||
fmt = (medium.get('format') or '').strip()
|
||
if fmt and fmt not in formats:
|
||
formats.append(fmt)
|
||
return ', '.join(formats)
|
||
|
||
def _release_label(self, release: Dict[str, Any]) -> str:
|
||
for info in release.get('label-info', []) or []:
|
||
label = (info.get('label') or {}) if isinstance(info, dict) else {}
|
||
name = (label.get('name') or '').strip()
|
||
if name:
|
||
return name
|
||
return ''
|
||
|
||
def _release_to_album(self, release: Dict[str, Any],
|
||
fallback_artist_name: Optional[str] = None) -> Optional[Album]:
|
||
"""Project a concrete MusicBrainz release into our Album dataclass."""
|
||
mbid = release.get('id', '')
|
||
title = release.get('title', '') or ''
|
||
if not title:
|
||
return None
|
||
|
||
artists = _extract_artist_credit(release.get('artist-credit', []))
|
||
if not artists and fallback_artist_name:
|
||
artists = [fallback_artist_name]
|
||
|
||
rg = release.get('release-group', {}) or {}
|
||
primary_type = rg.get('primary-type', '') or ''
|
||
secondary_types = rg.get('secondary-types', []) or []
|
||
album_type = _map_release_type(primary_type, secondary_types)
|
||
rg_mbid = rg.get('id', '') or release.get('release-group-id', '')
|
||
image_url = self._cached_art(mbid, rg_mbid)
|
||
|
||
return Album(
|
||
id=mbid,
|
||
name=title,
|
||
artists=artists if artists else ['Unknown Artist'],
|
||
release_date=release.get('date', '') or '',
|
||
total_tracks=self._release_total_tracks(release),
|
||
album_type=album_type,
|
||
image_url=image_url,
|
||
external_urls={'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {},
|
||
format=self._release_formats(release) or None,
|
||
country=(release.get('country') or '').strip() or None,
|
||
status=(release.get('status') or '').strip() or None,
|
||
label=self._release_label(release) or None,
|
||
disambiguation=(release.get('disambiguation') or '').strip() or None,
|
||
release_group_id=rg_mbid or None,
|
||
)
|
||
|
||
def _release_variant_key(self, album: Album):
|
||
status_rank = 0 if (album.status or '').lower() == 'official' else 1
|
||
date = (album.release_date or '9999-99-99')[:10] or '9999-99-99'
|
||
track_rank = album.total_tracks or 9999
|
||
country_rank = 0 if (album.country or '') in ('XW', 'US', 'GB') else 1
|
||
return (
|
||
status_rank,
|
||
date,
|
||
country_rank,
|
||
track_rank,
|
||
album.format or '',
|
||
album.disambiguation or '',
|
||
album.id,
|
||
)
|
||
|
||
def _release_group_releases_to_albums(self, rg: Dict[str, Any], artist_name: str,
|
||
limit: int) -> List[Album]:
|
||
rg_mbid = rg.get('id', '')
|
||
if not rg_mbid:
|
||
return []
|
||
|
||
releases = self._client.browse_release_group_releases(rg_mbid, limit=max(limit, 25))
|
||
albums = []
|
||
for release in releases:
|
||
release.setdefault('release-group', rg)
|
||
album = self._release_to_album(release, fallback_artist_name=artist_name)
|
||
if album:
|
||
albums.append(album)
|
||
albums.sort(key=self._release_variant_key)
|
||
return albums[:limit]
|
||
|
||
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
|
||
"""Search MusicBrainz for releases (albums).
|
||
|
||
Primary path: when the query looks like a bare artist name, resolve
|
||
it to an artist MBID and BROWSE that artist's release-groups. This
|
||
returns the artist's actual discography instead of unrelated
|
||
releases that happen to be titled after them.
|
||
|
||
Fallback path: when the query is structured as "Artist - Album" or
|
||
the artist lookup fails, drop back to text search with the
|
||
existing Lucene strategy.
|
||
"""
|
||
try:
|
||
artist_name, title = self._split_structured_query(query)
|
||
|
||
# Structured "Artist - Album" query → respect user's intent;
|
||
# text-search with both terms is more precise than browsing all
|
||
# of that artist's discography.
|
||
if artist_name:
|
||
return self._search_albums_text(title, artist_name, limit)
|
||
|
||
# Bare name query → try artist-first → browse path.
|
||
top = self._resolve_top_artist(query)
|
||
if top:
|
||
mbid = top.get('id', '')
|
||
tname = top.get('name', '') or query
|
||
# If the query has words beyond the artist name (e.g. "The
|
||
# Beatles Abbey Road"), extract the leftover as a title hint.
|
||
# We'll use it below to narrow browse results to the specific
|
||
# album the user typed rather than dumping the full back
|
||
# catalogue. kettui flagged the regression — bare-name browse
|
||
# was burying a specific-album query inside a discography list.
|
||
title_hint = _extract_title_hint(query, tname)
|
||
rgs = self._client.browse_artist_release_groups(
|
||
mbid,
|
||
# 'compilation' is a SECONDARY type, not a primary type
|
||
# — including it in the OR filter causes MB to return
|
||
# only 82 matches instead of the actual 1076 because
|
||
# the filter silently breaks. Actual compilations
|
||
# (primary-type=Album with secondary-types=[Compilation])
|
||
# are handled by the studio-preference filter below.
|
||
# 'other' added per issue #650 — MB tags music videos
|
||
# and one-off web/broadcast releases with primary=Other,
|
||
# and many artists (Vocaloid producers, indie acts, JP
|
||
# solo artists) have legitimate singles classified
|
||
# there. Pre-fix this filter dropped them at the API
|
||
# layer, hiding tracks the user had downloaded.
|
||
# `map_release_group_type` routes 'other' into the
|
||
# singles bucket so they appear in the right UI section.
|
||
release_types=['album', 'ep', 'single', 'other'],
|
||
limit=100,
|
||
)
|
||
|
||
# Prefer studio releases — MusicBrainz tags live bootlegs
|
||
# and best-of compilations with secondary-types. For mega-
|
||
# artists like Metallica, 83 of 100 browse results are live
|
||
# broadcast bootlegs; the 12 studio albums are buried. A
|
||
# release-group with no secondary-types (or an explicit
|
||
# studio-only type) is the "original studio" shape users
|
||
# expect to see first.
|
||
def _is_studio(rg):
|
||
secs = set((rg.get('secondary-types') or []))
|
||
return not (secs & {'Live', 'Compilation', 'Soundtrack',
|
||
'Remix', 'Demo', 'Mixtape/Street',
|
||
'Interview', 'Audiobook', 'Audio drama'})
|
||
studio = [rg for rg in rgs if _is_studio(rg)]
|
||
# If filtering leaves us empty (niche live-only artist),
|
||
# fall back to the unfiltered list — better than no results.
|
||
rgs = studio or rgs
|
||
|
||
# Narrow to the title-hint if the user gave one ("The Beatles
|
||
# Abbey Road" → filter to RGs whose title contains "abbey
|
||
# road"). If no RG matches, fall back to text-search so the
|
||
# user finds the specific album instead of either seeing the
|
||
# full discography or getting zero results. (kettui flagged
|
||
# this regression — artist-first alone was burying specific-
|
||
# album queries inside the unfiltered discography list.)
|
||
if title_hint:
|
||
hint_lower = title_hint.lower()
|
||
matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()]
|
||
if matched:
|
||
rgs = matched
|
||
expanded = []
|
||
for rg in rgs:
|
||
expanded.extend(self._release_group_releases_to_albums(rg, tname, limit))
|
||
if len(expanded) >= limit:
|
||
break
|
||
if expanded:
|
||
return expanded[:limit]
|
||
else:
|
||
fallback = self._search_albums_text(title_hint, tname, limit)
|
||
if fallback:
|
||
return fallback
|
||
# Text-search also missed — fall through and show the
|
||
# full (unfiltered) discography rather than nothing.
|
||
|
||
# Sort by primary-type priority first (album > ep > single >
|
||
# compilation), then chronologically ASC — the standard way
|
||
# discographies are listed ("their debut was X, then Y, then Z").
|
||
type_priority = {'album': 0, 'ep': 1, 'single': 2, 'compilation': 3}
|
||
def _sort_key(rg):
|
||
pt = (rg.get('primary-type') or '').lower()
|
||
date = rg.get('first-release-date') or ''
|
||
year = int(date[:4]) if date[:4].isdigit() else 9999
|
||
return (type_priority.get(pt, 9), year)
|
||
rgs.sort(key=_sort_key)
|
||
albums = [self._release_group_to_album(rg, tname) for rg in rgs[:limit]]
|
||
return albums
|
||
|
||
# No artist match → text search on the whole query.
|
||
return self._search_albums_text(query, None, limit)
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz album search failed: {e}")
|
||
return []
|
||
|
||
def _search_albums_text(self, album_name: str, artist_name: Optional[str], limit: int) -> List[Album]:
|
||
"""Fallback text-search path for structured/fuzzy album queries."""
|
||
try:
|
||
results = self._client.search_release(album_name, artist_name=artist_name, limit=limit)
|
||
# Score filter — same threshold as artists. Drops garbage
|
||
# title-match hits from unrelated releases.
|
||
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
|
||
|
||
albums = []
|
||
for r in results:
|
||
album = self._release_to_album(r)
|
||
if album:
|
||
albums.append(album)
|
||
|
||
# Keep distinct MusicBrainz releases. The same title/artist/date
|
||
# can represent explicit, clean, regional, format, or bonus-track
|
||
# variants with different tracklists, which manual import must let
|
||
# the user choose.
|
||
seen_ids = set()
|
||
unique = []
|
||
for album in albums:
|
||
if album.id and album.id in seen_ids:
|
||
continue
|
||
if album.id:
|
||
seen_ids.add(album.id)
|
||
unique.append(album)
|
||
unique.sort(key=self._release_variant_key)
|
||
return unique[:limit]
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz album search failed: {e}")
|
||
return []
|
||
|
||
def _recording_to_track(self, r: Dict[str, Any], fallback_artist_name: str) -> Optional[Track]:
|
||
"""Project a MusicBrainz recording into our Track dataclass. Returns
|
||
None when the recording lacks required fields."""
|
||
mbid = r.get('id', '')
|
||
title = r.get('title', '')
|
||
if not title:
|
||
return None
|
||
|
||
artists = _extract_artist_credit(r.get('artist-credit', []))
|
||
if not artists and fallback_artist_name:
|
||
artists = [fallback_artist_name]
|
||
|
||
duration_ms = r.get('length', 0) or 0
|
||
album_name = ''
|
||
album_id = ''
|
||
release_date = ''
|
||
image_url = None
|
||
album_type = 'single'
|
||
# Initialized to 0 and summed from the release's media track-counts.
|
||
# Previously initialized to 1, which made every track-with-release
|
||
# report one more than the album actually has (kettui caught this).
|
||
total_tracks = 0
|
||
|
||
releases = r.get('releases', []) or []
|
||
if releases:
|
||
rel = releases[0]
|
||
album_name = rel.get('title', '') or ''
|
||
album_id = rel.get('id', '') or ''
|
||
release_date = rel.get('date', '') or ''
|
||
|
||
rg = rel.get('release-group', {}) or {}
|
||
primary_type = rg.get('primary-type', '') or ''
|
||
secondary_types = rg.get('secondary-types', []) or []
|
||
album_type = _map_release_type(primary_type, secondary_types)
|
||
|
||
for m in rel.get('media', []) or []:
|
||
total_tracks += m.get('track-count', 0)
|
||
|
||
rg_mbid = rg.get('id', '') or ''
|
||
image_url = self._cached_art(album_id, rg_mbid) if album_id else None
|
||
|
||
# Tracks with no release info are standalone recordings — give them
|
||
# total_tracks=1 (the track itself). Keeps the old shape for that
|
||
# edge case but fixes the off-by-one for every normal case.
|
||
if not releases:
|
||
total_tracks = 1
|
||
|
||
return Track(
|
||
id=mbid,
|
||
name=title,
|
||
artists=artists if artists else ['Unknown Artist'],
|
||
album=album_name or title,
|
||
duration_ms=duration_ms,
|
||
popularity=r.get('score', 0) or 0,
|
||
image_url=image_url,
|
||
release_date=release_date,
|
||
external_urls={'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {},
|
||
album_type=album_type,
|
||
total_tracks=total_tracks,
|
||
album_id=album_id,
|
||
)
|
||
|
||
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
|
||
"""Search MusicBrainz for recordings (tracks).
|
||
|
||
Same strategy as `search_albums`: bare name → artist-first → browse
|
||
recordings; structured "Artist - Title" stays on text search so the
|
||
user's explicit title intent is respected.
|
||
"""
|
||
try:
|
||
artist_name, title = self._split_structured_query(query)
|
||
|
||
# Structured query → text search with both fields.
|
||
if artist_name:
|
||
return self._search_tracks_text(title, artist_name, limit)
|
||
|
||
# Bare name → artist-first → arid: search.
|
||
top = self._resolve_top_artist(query)
|
||
if top:
|
||
mbid = top.get('id', '')
|
||
tname = top.get('name', '') or query
|
||
# /recording?artist=<mbid> (browse) rejects inc=releases,
|
||
# so we use the fielded Lucene search arid:<mbid> instead —
|
||
# that returns recordings with release context inline.
|
||
recs = self._client.search_recordings_by_artist_mbid(mbid, limit=100)
|
||
|
||
# Re-order each recording's releases to prefer studio over
|
||
# live/compilation. Without this, the first release (which
|
||
# the adapter uses for album info + date) is often a random
|
||
# live bootleg — Metallica has 10+ live versions of "One"
|
||
# ranked ahead of the studio release. Mutates in place so
|
||
# `_recording_to_track` sees the preferred release first.
|
||
for r in recs:
|
||
rels = r.get('releases') or []
|
||
if not rels:
|
||
continue
|
||
rels.sort(key=self._release_preference_key)
|
||
r['releases'] = rels
|
||
|
||
# Prefer recordings that have at least one studio release.
|
||
# Falls back to the full set if the artist is live-only.
|
||
studio = [r for r in recs if self._has_studio_release(r)]
|
||
recs = studio or recs
|
||
|
||
# Dedupe by normalized title (MB has many versions of the
|
||
# same song — live, remaster, re-recording, etc.). Because
|
||
# we sorted releases above, `_recording_to_track` will pick
|
||
# the studio release for album info on the first keeper.
|
||
seen = set()
|
||
deduped = []
|
||
for r in recs:
|
||
key = (r.get('title') or '').lower().strip()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append(r)
|
||
|
||
# Sort by studio-release year ASC so classic tracks surface
|
||
# first. For a user typing "metallica", this means "Seek
|
||
# and Destroy" (1983) before "Atlas, Rise!" (2016) — which
|
||
# matches how most discography views order by release.
|
||
def _track_sort_key(r):
|
||
rels = r.get('releases') or []
|
||
for rel in rels:
|
||
date = (rel.get('date') or '')[:4]
|
||
if date.isdigit():
|
||
return int(date)
|
||
return 9999
|
||
deduped.sort(key=_track_sort_key)
|
||
|
||
tracks = []
|
||
for r in deduped[:limit]:
|
||
t = self._recording_to_track(r, tname)
|
||
if t:
|
||
tracks.append(t)
|
||
return tracks
|
||
|
||
# No artist match → fall back to text search on whole query.
|
||
return self._search_tracks_text(query, None, limit)
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz track search failed: {e}")
|
||
return []
|
||
|
||
def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int,
|
||
strict: bool = True, min_score: Optional[int] = None) -> List[Track]:
|
||
"""Fallback text-search path for structured/fuzzy track queries.
|
||
|
||
`strict=True` (default) keeps the field-scoped Lucene phrase match —
|
||
precise enough for enrichment-style flows where the inputs are
|
||
already known-clean. `strict=False` switches to a bare-query
|
||
MB lookup that hits alias/sortname indexes with diacritic folding —
|
||
needed for user-facing fuzzy surfaces (Fix popup cascade) where
|
||
recall beats precision because the user picks from the result list.
|
||
Mirrors the same toggle already on `search_recording` in
|
||
`core/musicbrainz_client.py`.
|
||
|
||
`min_score` defaults to `self._MIN_SCORE` (80) — sized for the
|
||
enhanced search tab where unfiltered MB results are noisy. Pass
|
||
a lower value (or 0) when a downstream stage like
|
||
`core.metadata.relevance.rerank_tracks` will re-sort by artist
|
||
match — MB's free-text score heavily favours title-text matches
|
||
("Army of Me (Bjork)" cover by HIRS Collective scores 100,
|
||
Björk's canonical "Army of Me" scores 28) so a high floor drops
|
||
the right answer.
|
||
"""
|
||
try:
|
||
results = self._client.search_recording(
|
||
track_name, artist_name=artist_name, limit=limit, strict=strict
|
||
)
|
||
threshold = self._MIN_SCORE if min_score is None else min_score
|
||
results = [r for r in results if (r.get('score', 0) or 0) >= threshold]
|
||
|
||
tracks = []
|
||
for r in results:
|
||
t = self._recording_to_track(r, artist_name or '')
|
||
if t:
|
||
tracks.append(t)
|
||
return tracks
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz track search failed: {e}")
|
||
return []
|
||
|
||
def search_tracks_with_artist(self, track: str, artist: str,
|
||
limit: int = 10) -> List[Track]:
|
||
"""Search MB tracks with track + artist passed as separate fields.
|
||
|
||
Powers the Fix-popup metadata cascade (`GET /api/musicbrainz/search_tracks`)
|
||
and any future surface where the caller already has the title/artist
|
||
split and wants the fuzzy-recall MB lookup without going through
|
||
`search_tracks`'s structured-query dispatch (`Artist - Track`
|
||
splitting, bare-name artist-first browse).
|
||
|
||
When both fields are present: strict-first, bare-as-fallback. The
|
||
strict pass builds a field-scoped Lucene query (`recording:"<t>"
|
||
AND artist:"<a>"`) which anchors the artist and prunes title-
|
||
collision covers — fixes the "Coffee Break" + "Zeds Dead" case
|
||
where MB's title-text-biased scorer surfaced Emapea / Vidalias /
|
||
West One Orchestra ahead of the canonical Zeds Dead recording.
|
||
`min_score=0` on strict because the field-scoped query is itself
|
||
precise, and the endpoint's `rerank_tracks` pass does the final
|
||
ordering. Bare query runs only when strict returns nothing —
|
||
catches diacritic / alias mismatches (`Bjork` query vs canonical
|
||
`Björk` artist) where strict phrase match never hits.
|
||
|
||
When only one field is present: bare-query mode directly — same
|
||
recall-over-precision tradeoff the old single-path took.
|
||
|
||
Callers wanting MB-specific length-preference ordering (multi-
|
||
edition recordings where some lack length data) should pass
|
||
``prefer_known_duration=True`` to ``rerank_tracks`` downstream
|
||
— a stable sort here would just be re-sorted away by the rerank
|
||
pass anyway.
|
||
"""
|
||
if not track and not artist:
|
||
return []
|
||
|
||
if track and artist:
|
||
results = self._search_tracks_text(
|
||
track, artist, limit, strict=True, min_score=0
|
||
)
|
||
if not results:
|
||
results = self._search_tracks_text(
|
||
track, artist, limit, strict=False, min_score=20
|
||
)
|
||
return results
|
||
|
||
return self._search_tracks_text(
|
||
track, artist or None, limit, strict=False, min_score=20
|
||
)
|
||
|
||
def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||
"""Pick the best release out of a release-group's editions.
|
||
|
||
Release-groups often contain 5-20+ releases (original, reissues,
|
||
remasters, regional editions, bonus-track editions). We want a
|
||
single canonical version to show the user as 'the album.' Prefer:
|
||
1. Official releases (not promo/bootleg)
|
||
2. Earliest date (the original)
|
||
3. Any release with media (skip entries that are just stubs)
|
||
"""
|
||
if not releases:
|
||
return None
|
||
|
||
def _key(r):
|
||
status = (r.get('status') or '').lower()
|
||
status_rank = 0 if status == 'official' else 1 # Official first
|
||
has_media = 0 if r.get('media') else 1 # Real tracklists first
|
||
date = (r.get('date') or '9999-99-99')[:10]
|
||
return (has_media, status_rank, date)
|
||
|
||
return sorted(releases, key=_key)[0]
|
||
|
||
def is_authenticated(self) -> bool:
|
||
return True
|
||
|
||
def reload_config(self) -> None:
|
||
pass
|
||
|
||
def get_track_features(self, track_id: str) -> None:
|
||
return None
|
||
|
||
def get_user_info(self) -> None:
|
||
return None
|
||
|
||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Return Spotify-compatible track detail dict by recording MBID."""
|
||
try:
|
||
rec = self._client.get_recording(track_id, includes=['releases', 'artist-credits', 'release-groups'])
|
||
if not rec:
|
||
return None
|
||
releases = rec.get('releases', []) or []
|
||
releases.sort(key=self._release_preference_key)
|
||
first_rel = releases[0] if releases else {}
|
||
rg = first_rel.get('release-group', {}) or {}
|
||
release_id = first_rel.get('id', '')
|
||
rg_id = rg.get('id', '')
|
||
image_url = self._cached_art(release_id, rg_id)
|
||
artists = _extract_artist_credit(rec.get('artist-credit', []))
|
||
return {
|
||
'id': rec.get('id', ''),
|
||
'name': rec.get('title', ''),
|
||
'artists': [{'name': a, 'id': ''} for a in artists],
|
||
'album': {
|
||
'id': rg_id or release_id,
|
||
'name': first_rel.get('title', ''),
|
||
'images': [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [],
|
||
'release_date': first_rel.get('date') or rg.get('first-release-date') or '',
|
||
},
|
||
'duration_ms': rec.get('length') or 0,
|
||
'track_number': 1,
|
||
'disc_number': 1,
|
||
'preview_url': None,
|
||
'popularity': 0,
|
||
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{track_id}'},
|
||
}
|
||
except Exception as e:
|
||
logger.error(f'get_track_details({track_id}) error: {e}')
|
||
return None
|
||
|
||
def get_recording_flat(self, mbid: str) -> Optional[Dict[str, Any]]:
|
||
"""Return a Fix-popup-compatible flat track dict by recording MBID.
|
||
|
||
Distinct from `get_track_details` which returns a Spotify-shaped
|
||
nested dict (artists as objects, album as nested object with
|
||
images array). The Discovery Fix popup expects the flat shape that
|
||
the spotify/deezer/itunes search endpoints produce — artists as a
|
||
list of strings, album as a string, single image_url field.
|
||
|
||
Used by `GET /api/musicbrainz/recording/<mbid>` to support the
|
||
MBID-paste lookup field — power-user escape hatch when fuzzy auto-
|
||
search ranks the wrong recording among many same-title versions.
|
||
|
||
Returns None when the MBID is missing or MB returns no recording.
|
||
Recording-without-release is valid (album = '', image_url = '').
|
||
"""
|
||
if not mbid:
|
||
return None
|
||
try:
|
||
rec = self._client.get_recording(
|
||
mbid, includes=['releases', 'artist-credits', 'release-groups']
|
||
)
|
||
if not rec:
|
||
return None
|
||
|
||
releases = rec.get('releases', []) or []
|
||
releases.sort(key=self._release_preference_key)
|
||
first_rel = releases[0] if releases else {}
|
||
rg = first_rel.get('release-group', {}) or {}
|
||
release_id = first_rel.get('id', '')
|
||
rg_id = rg.get('id', '')
|
||
|
||
artists = _extract_artist_credit(rec.get('artist-credit', []))
|
||
album_name = first_rel.get('title', '') or ''
|
||
image_url = self._cached_art(release_id, rg_id) if (release_id or rg_id) else None
|
||
|
||
return {
|
||
'id': rec.get('id', '') or mbid,
|
||
'name': rec.get('title', '') or '',
|
||
'artists': artists if artists else [],
|
||
'album': album_name,
|
||
'duration_ms': rec.get('length') or 0,
|
||
'image_url': image_url or '',
|
||
'external_urls': {
|
||
'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'
|
||
},
|
||
}
|
||
except Exception as e:
|
||
logger.error(f'get_recording_flat({mbid}) error: {e}')
|
||
return None
|
||
|
||
def get_album_tracks(self, album_mbid: str) -> Optional[Dict[str, Any]]:
|
||
"""Return {items: [...], total: N} track listing for a release/release-group MBID."""
|
||
album = self.get_album(album_mbid, include_tracks=True)
|
||
if album is None:
|
||
return None
|
||
flat = album.get('tracks', [])
|
||
if isinstance(flat, dict):
|
||
return flat
|
||
return {'items': flat, 'total': len(flat)}
|
||
|
||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Return Spotify-compatible artist detail dict."""
|
||
try:
|
||
artist = self._client.get_artist(artist_id, includes=['tags', 'url-rels'])
|
||
if not artist:
|
||
return None
|
||
genres = [t['name'] for t in (artist.get('tags') or []) if isinstance(t, dict) and t.get('name')]
|
||
return {
|
||
'id': artist.get('id', artist_id),
|
||
'name': artist.get('name', ''),
|
||
'genres': genres,
|
||
'followers': {'total': 0},
|
||
'popularity': 0,
|
||
'images': [],
|
||
'external_urls': {'musicbrainz': f'https://musicbrainz.org/artist/{artist_id}'},
|
||
}
|
||
except Exception as e:
|
||
logger.error(f'get_artist({artist_id}) error: {e}')
|
||
return None
|
||
|
||
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||
"""Return top recordings for an artist, deduplicated by title and sorted by year."""
|
||
try:
|
||
recs = self._client.search_recordings_by_artist_mbid(artist_id, limit=100)
|
||
for r in recs:
|
||
rels = r.get('releases') or []
|
||
if rels:
|
||
rels.sort(key=self._release_preference_key)
|
||
r['releases'] = rels
|
||
studio = [r for r in recs if self._has_studio_release(r)]
|
||
recs = studio or recs
|
||
seen: set = set()
|
||
deduped = []
|
||
for r in recs:
|
||
key = (r.get('title') or '').lower().strip()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append(r)
|
||
results = []
|
||
for r in deduped[:limit]:
|
||
releases = r.get('releases', [])
|
||
first_rel = releases[0] if releases else {}
|
||
rg = first_rel.get('release-group', {}) or {}
|
||
release_id = first_rel.get('id', '')
|
||
rg_id = rg.get('id', '')
|
||
artists = _extract_artist_credit(r.get('artist-credit', []))
|
||
image_url = self._cached_art(release_id, rg_id)
|
||
results.append({
|
||
'id': r.get('id', ''),
|
||
'name': r.get('title', ''),
|
||
'artists': [{'name': a, 'id': ''} for a in artists],
|
||
'album': {
|
||
'id': rg_id or release_id,
|
||
'name': first_rel.get('title', ''),
|
||
'images': [{'url': image_url}] if image_url else [],
|
||
},
|
||
'duration_ms': r.get('length') or 0,
|
||
'popularity': 0,
|
||
'preview_url': None,
|
||
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{r.get("id", "")}'},
|
||
})
|
||
return results
|
||
except Exception as e:
|
||
logger.error(f'get_artist_top_tracks({artist_id}) error: {e}')
|
||
return []
|
||
|
||
def get_album(self, album_mbid: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
||
"""Get full album details with track listing for download modal.
|
||
|
||
The MBID passed in could be either:
|
||
- A release-group MBID (from `search_albums` browse path — the
|
||
common case now that bare-name searches route artist-first →
|
||
browse), or
|
||
- A release MBID (from the text-search fallback path).
|
||
|
||
Try release-group first since that's the majority; if it 404s,
|
||
fall back to direct release lookup. Release-group resolution adds
|
||
one extra API call (~1s at the 1-rps rate limit) to pick a
|
||
representative release and then fetch its tracklist.
|
||
"""
|
||
try:
|
||
# Path A: release-group MBID (new browse-based search default)
|
||
rg = self._client.get_release_group(
|
||
album_mbid, includes=['releases', 'artist-credits']
|
||
)
|
||
if rg:
|
||
releases = rg.get('releases') or []
|
||
rep = self._pick_representative_release(releases)
|
||
if rep and rep.get('id'):
|
||
album = self._render_release_as_album(
|
||
rep['id'],
|
||
rg_fallback=rg,
|
||
)
|
||
if album:
|
||
# Keep the release-group MBID as the canonical
|
||
# Album.id so downstream code can re-fetch with
|
||
# the same URL.
|
||
album['id'] = album_mbid
|
||
album['external_urls'] = {
|
||
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
|
||
}
|
||
if not include_tracks:
|
||
album.pop('tracks', None)
|
||
return album
|
||
|
||
# Path B: release MBID (text-search fallback path)
|
||
album = self._render_release_as_album(album_mbid)
|
||
if album and not include_tracks:
|
||
album.pop('tracks', None)
|
||
return album
|
||
except Exception as e:
|
||
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
|
||
return None
|
||
|
||
def _render_release_as_album(self, release_mbid: str,
|
||
rg_fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||
"""Fetch a specific release and project it to the album-detail dict
|
||
shape the download modal expects. `rg_fallback` supplies release-group
|
||
metadata (type, artist credits) when resolving from a release-group
|
||
whose releases may be lightly populated."""
|
||
# NOTE: `cover-art-archive` is NOT a valid `inc` param for the
|
||
# /release resource — MB returns 400 if you pass it. The CAA flags
|
||
# (`{'front': True, 'back': True, ...}`) come back on every release
|
||
# response by default, so we read them below without requesting an
|
||
# include.
|
||
release = self._client.get_release(
|
||
release_mbid, includes=['recordings', 'artist-credits', 'release-groups']
|
||
)
|
||
if not release:
|
||
return None
|
||
|
||
title = release.get('title', '')
|
||
artists_raw = _extract_artist_credit(release.get('artist-credit', []))
|
||
if not artists_raw and rg_fallback:
|
||
artists_raw = _extract_artist_credit(rg_fallback.get('artist-credit', []))
|
||
release_date = release.get('date', '') or ''
|
||
if not release_date and rg_fallback:
|
||
release_date = rg_fallback.get('first-release-date', '') or ''
|
||
|
||
rg = release.get('release-group', rg_fallback or {}) or {}
|
||
primary_type = rg.get('primary-type', '') or ''
|
||
secondary_types = rg.get('secondary-types', []) or []
|
||
album_type = _map_release_type(primary_type, secondary_types)
|
||
|
||
rg_mbid = rg.get('id', '')
|
||
# Use cover-art-archive metadata to pick the right CAA scope.
|
||
# release-group scope is preferred (covers all editions), but only
|
||
# if the release itself actually has front art — otherwise that URL
|
||
# will 404. `cover-art-archive.front` is authoritative with no
|
||
# extra network call (returned as part of the release fetch above).
|
||
caa = release.get('cover-art-archive') or {}
|
||
if caa.get('front'):
|
||
image_url = _cover_art_url(release_mbid, scope='release')
|
||
elif rg_mbid:
|
||
image_url = _cover_art_url(rg_mbid, scope='release-group')
|
||
else:
|
||
image_url = None
|
||
|
||
tracks = []
|
||
total_tracks = 0
|
||
media_list = release.get('media', [])
|
||
for media_idx, media in enumerate(media_list):
|
||
disc_number = media.get('position', media_idx + 1)
|
||
for track in media.get('tracks', []):
|
||
total_tracks += 1
|
||
recording = track.get('recording', {})
|
||
track_artists = _extract_artist_credit(recording.get('artist-credit', []))
|
||
if not track_artists:
|
||
track_artists = artists_raw
|
||
|
||
try:
|
||
track_num = int(track.get('number', track.get('position', total_tracks)))
|
||
except (ValueError, TypeError):
|
||
track_num = total_tracks
|
||
|
||
tracks.append({
|
||
'id': recording.get('id', track.get('id', '')),
|
||
'name': recording.get('title', track.get('title', '')),
|
||
'artists': [{'name': a} for a in track_artists],
|
||
'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0,
|
||
'track_number': track_num,
|
||
'disc_number': disc_number,
|
||
})
|
||
|
||
images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else []
|
||
|
||
return {
|
||
'id': release_mbid,
|
||
'name': title,
|
||
'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])],
|
||
'release_date': release_date,
|
||
'total_tracks': total_tracks,
|
||
'album_type': album_type,
|
||
'images': images,
|
||
'tracks': tracks,
|
||
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
|
||
'format': self._release_formats(release),
|
||
'country': release.get('country') or '',
|
||
'status': release.get('status') or '',
|
||
'label': self._release_label(release),
|
||
'disambiguation': release.get('disambiguation') or '',
|
||
'release_group_id': rg_mbid,
|
||
}
|
||
|
||
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
|
||
"""Get an artist's full discography for the artist-detail view.
|
||
|
||
Walks the paginated browse endpoint (`/release-group?artist=<mbid>`)
|
||
instead of the artist lookup's embedded release-groups. The lookup
|
||
(`/artist/<mbid>?inc=release-groups`) is hard-capped at 25 release-
|
||
groups by MusicBrainz — and ignores the `limit` param entirely — so
|
||
prolific artists had ~85% of their catalogue silently dropped. That
|
||
was the bug Sokhi reported: "a lot of albums are missing vs what's
|
||
showing on the site" (e.g. Kendrick Lamar — 167 release-groups on
|
||
MB, only the first 25 ever reached SoulSync).
|
||
|
||
Unlike the search path there is NO `type` filter and NO studio-only
|
||
filter here: the artist-detail page wants the *whole* catalogue —
|
||
albums, EPs, singles, compilations, soundtracks, live — so its tabs
|
||
mirror musicbrainz.org. `_release_group_to_album` maps each release-
|
||
group's primary/secondary types into the right bucket.
|
||
"""
|
||
try:
|
||
# Lightweight lookup purely for the canonical artist name — the
|
||
# browse endpoint doesn't carry it. Non-fatal: on failure each
|
||
# Album falls back to 'Unknown Artist' via _release_group_to_album.
|
||
artist = self._client.get_artist(artist_mbid)
|
||
artist_name = (artist or {}).get('name', '') or ''
|
||
|
||
page_size = 100 # MusicBrainz browse hard cap per page
|
||
albums: List[Album] = []
|
||
seen: set = set()
|
||
offset = 0
|
||
while len(albums) < limit:
|
||
page = self._client.browse_artist_release_groups(
|
||
artist_mbid,
|
||
# No type filter — fetch every primary type. Secondary
|
||
# types (Compilation/Soundtrack/Live) ride along on their
|
||
# Album/Single/EP parent and are bucketed downstream.
|
||
# (Filtering by type=compilation is intentionally avoided:
|
||
# it's a secondary type and silently breaks MB's filter —
|
||
# see browse_artist_release_groups docs.)
|
||
release_types=None,
|
||
limit=page_size,
|
||
offset=offset,
|
||
)
|
||
if not page:
|
||
break
|
||
for rg in page:
|
||
rg_mbid = rg.get('id', '')
|
||
if rg_mbid and rg_mbid in seen:
|
||
continue
|
||
if rg_mbid:
|
||
seen.add(rg_mbid)
|
||
albums.append(self._release_group_to_album(rg, artist_name))
|
||
if len(page) < page_size:
|
||
break # last page reached
|
||
offset += page_size
|
||
return albums[:limit]
|
||
except Exception as e:
|
||
logger.warning(f"MusicBrainz artist albums failed: {e}")
|
||
return []
|