Expand matched MusicBrainz release groups into concrete releases for specific album searches so import users can choose the correct edition by track count, format, country, and disambiguation. Preserve distinct MusicBrainz release IDs instead of deduping same-title variants, carry release metadata through import matching, and surface those details on album result cards. Add coverage for variant preservation and release-group expansion.
502 lines
19 KiB
Python
502 lines
19 KiB
Python
import requests
|
|
import time
|
|
import threading
|
|
from typing import Dict, List, Optional, Any
|
|
from functools import wraps
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("musicbrainz_client")
|
|
|
|
# Global rate limiting variables
|
|
_last_api_call_time = 0
|
|
_api_call_lock = threading.Lock()
|
|
MIN_API_INTERVAL = 1.0 # 1 second between API calls (MusicBrainz requirement)
|
|
|
|
def rate_limited(func):
|
|
"""Decorator to enforce rate limiting on MusicBrainz API calls"""
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
global _last_api_call_time
|
|
|
|
with _api_call_lock:
|
|
current_time = time.time()
|
|
time_since_last_call = current_time - _last_api_call_time
|
|
|
|
if time_since_last_call < MIN_API_INTERVAL:
|
|
sleep_time = MIN_API_INTERVAL - time_since_last_call
|
|
time.sleep(sleep_time)
|
|
|
|
_last_api_call_time = time.time()
|
|
|
|
from core.api_call_tracker import api_call_tracker
|
|
api_call_tracker.record_call('musicbrainz')
|
|
|
|
try:
|
|
result = func(*args, **kwargs)
|
|
return result
|
|
except Exception as e:
|
|
# Implement exponential backoff for API errors
|
|
if "rate limit" in str(e).lower() or "503" in str(e):
|
|
logger.warning(f"MusicBrainz rate limit hit, implementing backoff: {e}")
|
|
time.sleep(2.0) # Wait 2 seconds before retrying
|
|
raise e
|
|
return wrapper
|
|
|
|
class MusicBrainzClient:
|
|
"""Client for interacting with MusicBrainz API"""
|
|
|
|
BASE_URL = "https://musicbrainz.org/ws/2"
|
|
# MusicBrainz mandates a meaningful User-Agent with contact info. Falling back
|
|
# to a bare name/version risks IP blocking under load — include the project
|
|
# URL so MB operators have a way to reach us if we misbehave.
|
|
DEFAULT_CONTACT = "https://github.com/Nezreka/SoulSync"
|
|
|
|
def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""):
|
|
"""
|
|
Initialize MusicBrainz client
|
|
|
|
Args:
|
|
app_name: Name of the application
|
|
app_version: Version of the application
|
|
contact_email: Contact email or URL (defaults to project URL when empty)
|
|
"""
|
|
contact = contact_email or self.DEFAULT_CONTACT
|
|
self.user_agent = f"{app_name}/{app_version} ( {contact} )"
|
|
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'User-Agent': self.user_agent,
|
|
'Accept': 'application/json'
|
|
})
|
|
|
|
logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}")
|
|
|
|
@rate_limited
|
|
def search_artist(self, artist_name: str, limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search for artists by name.
|
|
|
|
Args:
|
|
artist_name: Name of the artist to search for
|
|
limit: Maximum number of results to return
|
|
strict: When True (default), builds a phrase-match query against
|
|
the `artist` field only — correct for enrichment flows that
|
|
already know the exact name. When False, sends a bare query
|
|
which MusicBrainz matches against the alias, artist, AND
|
|
sortname indexes — the right behavior for user-facing fuzzy
|
|
search (finds "Metallica" from typing "metalica", matches
|
|
aliased names, etc.).
|
|
|
|
Returns:
|
|
List of artist results with id, name, score, etc. MusicBrainz
|
|
assigns each result a `score` 0-100; the list is pre-sorted
|
|
score-descending by the server.
|
|
"""
|
|
try:
|
|
# Escape quotes and backslashes for Lucene query
|
|
safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
|
|
|
if strict:
|
|
query = f'artist:"{safe_name}"'
|
|
else:
|
|
# Bare query hits alias/artist/sortname indexes — much better
|
|
# recall for user typing. Still Lucene-escaped via the API's
|
|
# query parser.
|
|
query = safe_name
|
|
|
|
params = {
|
|
'query': query,
|
|
'fmt': 'json',
|
|
'limit': limit
|
|
}
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/artist",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
artists = data.get('artists', [])
|
|
|
|
logger.debug(f"Found {len(artists)} artists for query: {artist_name}")
|
|
return artists
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching for artist '{artist_name}': {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def search_release(self, album_name: str, artist_name: Optional[str] = None,
|
|
limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search for releases (albums) by name.
|
|
|
|
Args:
|
|
album_name: Name of the album to search for
|
|
artist_name: Optional artist name to narrow search
|
|
limit: Maximum number of results to return
|
|
strict: When True (default), builds a phrase-match Lucene query
|
|
against the `release` and `artist` fields — correct for
|
|
enrichment flows where exact name+artist are known. When
|
|
False, sends a bare query (album + artist joined) so MB
|
|
hits alias / sortname indexes and folds diacritics,
|
|
dramatically improving recall for user-facing fuzzy
|
|
lookups (e.g. the manual Fix popup).
|
|
|
|
Returns:
|
|
List of release results
|
|
"""
|
|
try:
|
|
if strict:
|
|
# Escape quotes and backslashes for Lucene query
|
|
safe_album = album_name.replace('\\', '\\\\').replace('"', '\\"')
|
|
query = f'release:"{safe_album}"'
|
|
|
|
if artist_name:
|
|
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
|
query += f' AND artist:"{safe_artist}"'
|
|
else:
|
|
# Bare query — MB tokenizes against title + artist credit +
|
|
# alias + sortname indexes together with diacritic folding.
|
|
# Recovers cases like "Bjork" → "Björk" that strict phrase
|
|
# queries miss.
|
|
parts = [album_name]
|
|
if artist_name:
|
|
parts.append(artist_name)
|
|
query = ' '.join(p for p in parts if p)
|
|
|
|
params = {
|
|
'query': query,
|
|
'fmt': 'json',
|
|
'limit': limit
|
|
}
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/release",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
releases = data.get('releases', [])
|
|
|
|
logger.debug(f"Found {len(releases)} releases for query: {album_name}")
|
|
return releases
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching for release '{album_name}': {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def search_recording(self, track_name: str, artist_name: Optional[str] = None,
|
|
limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search for recordings (tracks) by name.
|
|
|
|
Args:
|
|
track_name: Name of the track to search for
|
|
artist_name: Optional artist name to narrow search
|
|
limit: Maximum number of results to return
|
|
strict: When True (default), builds a phrase-match Lucene query
|
|
against the `recording` and `artist` fields — correct for
|
|
enrichment flows where exact name+artist are known. When
|
|
False, sends a bare query (track + artist joined) so MB
|
|
hits alias / sortname indexes and folds diacritics. The
|
|
bare path also avoids the AND-clause that kills recall
|
|
when either side mis-matches (e.g. "Bjork" vs canonical
|
|
"Björk", or a track title with bracketed suffix like
|
|
"(Live)" that strict phrase match rejects).
|
|
|
|
Returns:
|
|
List of recording results
|
|
"""
|
|
try:
|
|
if strict:
|
|
# Escape quotes and backslashes for Lucene query
|
|
safe_track = track_name.replace('\\', '\\\\').replace('"', '\\"')
|
|
query = f'recording:"{safe_track}"'
|
|
|
|
if artist_name:
|
|
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
|
query += f' AND artist:"{safe_artist}"'
|
|
else:
|
|
# Bare query — see search_release for rationale.
|
|
parts = [track_name]
|
|
if artist_name:
|
|
parts.append(artist_name)
|
|
query = ' '.join(p for p in parts if p)
|
|
|
|
params = {
|
|
'query': query,
|
|
'fmt': 'json',
|
|
'limit': limit
|
|
}
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/recording",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
recordings = data.get('recordings', [])
|
|
|
|
logger.debug(f"Found {len(recordings)} recordings for query: {track_name}")
|
|
return recordings
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching for recording '{track_name}': {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def browse_artist_release_groups(self, artist_mbid: str,
|
|
release_types: Optional[List[str]] = None,
|
|
limit: int = 100,
|
|
offset: int = 0) -> List[Dict[str, Any]]:
|
|
"""Browse release-groups linked to an artist MBID.
|
|
|
|
This is the correct MusicBrainz pattern for "give me this artist's
|
|
discography" — text-based `/release?query=...` search would look at
|
|
release TITLES (matching unrelated releases literally titled after
|
|
the artist name), while browse walks the artist→release-group link
|
|
directly.
|
|
|
|
Args:
|
|
artist_mbid: Artist's MusicBrainz ID
|
|
release_types: Filter by primary type — any of 'album', 'single',
|
|
'ep', 'compilation', 'soundtrack', 'live', etc. Combined with
|
|
`|` per MB spec, e.g. `['album', 'ep']` → `type=album|ep`.
|
|
None returns all types.
|
|
limit: 1-100 (MB hard cap)
|
|
offset: Pagination offset
|
|
|
|
Returns:
|
|
List of release-group dicts. Each has `id`, `title`, `primary-type`,
|
|
`secondary-types`, `first-release-date`, `disambiguation`.
|
|
"""
|
|
try:
|
|
params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset}
|
|
if release_types:
|
|
params['type'] = '|'.join(release_types)
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/release-group",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
rgs = data.get('release-groups', [])
|
|
logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}")
|
|
return rgs
|
|
except Exception as e:
|
|
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def browse_release_group_releases(self, release_group_mbid: str,
|
|
limit: int = 100,
|
|
offset: int = 0) -> List[Dict[str, Any]]:
|
|
"""Browse concrete releases that belong to a release-group.
|
|
|
|
Release-groups identify the logical album; releases identify the
|
|
actual edition the user may own (country, format, explicit/clean
|
|
disambiguation, bonus tracks, track count). Manual import needs the
|
|
latter so users can choose the matching tracklist.
|
|
"""
|
|
try:
|
|
params = {
|
|
'release-group': release_group_mbid,
|
|
'fmt': 'json',
|
|
'limit': min(limit, 100),
|
|
'offset': offset,
|
|
'inc': 'artist-credits+media+labels+release-groups',
|
|
}
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/release",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
releases = data.get('releases', [])
|
|
logger.debug(f"Browsed {len(releases)} releases for release-group {release_group_mbid}")
|
|
return releases
|
|
except Exception as e:
|
|
logger.error(f"Error browsing releases for release-group {release_group_mbid}: {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def search_recordings_by_artist_mbid(self, artist_mbid: str,
|
|
limit: int = 100) -> List[Dict[str, Any]]:
|
|
"""Search for recordings linked to an artist via Lucene `arid:` query.
|
|
|
|
This is the counterpart to `browse_artist_release_groups` for tracks.
|
|
The proper "browse" endpoint (`/recording?artist=<mbid>`) rejects
|
|
`inc=releases`, so we can't get album context per recording from
|
|
browse — only the track title/length/MBID. Without release info the
|
|
user would see tracks with no album, which is useless.
|
|
|
|
The search endpoint with a fielded `arid:<mbid>` query returns
|
|
recordings with the `releases` array already embedded (including
|
|
release-group, date, and media info), which is what the search-tab
|
|
UI needs.
|
|
|
|
Args:
|
|
artist_mbid: Artist's MusicBrainz ID
|
|
limit: 1-100 (MB hard cap)
|
|
|
|
Returns:
|
|
List of recording dicts with `id`, `title`, `length`, `score`,
|
|
`artist-credit`, and `releases` (each with release-group + date).
|
|
"""
|
|
try:
|
|
params = {
|
|
'query': f'arid:{artist_mbid}',
|
|
'fmt': 'json',
|
|
'limit': min(limit, 100),
|
|
}
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/recording",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
recs = data.get('recordings', [])
|
|
logger.debug(f"Found {len(recs)} recordings for artist {artist_mbid}")
|
|
return recs
|
|
except Exception as e:
|
|
logger.error(f"Error searching recordings for artist {artist_mbid}: {e}")
|
|
return []
|
|
|
|
@rate_limited
|
|
def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get full artist details by MusicBrainz ID
|
|
|
|
Args:
|
|
mbid: MusicBrainz ID of the artist
|
|
includes: Optional list of additional data to include (e.g., 'url-rels', 'genres')
|
|
|
|
Returns:
|
|
Artist data or None if not found
|
|
"""
|
|
try:
|
|
params = {'fmt': 'json'}
|
|
if includes:
|
|
params['inc'] = '+'.join(includes)
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/artist/{mbid}",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching artist {mbid}: {e}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_release(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get full release details by MusicBrainz ID
|
|
|
|
Args:
|
|
mbid: MusicBrainz ID of the release
|
|
includes: Optional list of additional data to include
|
|
|
|
Returns:
|
|
Release data or None if not found
|
|
"""
|
|
try:
|
|
params = {'fmt': 'json'}
|
|
if includes:
|
|
params['inc'] = '+'.join(includes)
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/release/{mbid}",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching release {mbid}: {e}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_release_group(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
"""Get full release-group details by MBID.
|
|
|
|
Release-groups are the 'canonical album' entity in MusicBrainz —
|
|
they group every edition/reissue/region-specific release of the
|
|
same logical album under one MBID. Use `inc=releases` to list the
|
|
individual releases this group contains (each with its own
|
|
tracklist); use `inc=artist-credits` for artist info.
|
|
|
|
Args:
|
|
mbid: Release-group's MusicBrainz ID
|
|
includes: Optional list, e.g. ['releases', 'artist-credits']
|
|
|
|
Returns:
|
|
Release-group data or None if not found.
|
|
"""
|
|
try:
|
|
params = {'fmt': 'json'}
|
|
if includes:
|
|
params['inc'] = '+'.join(includes)
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/release-group/{mbid}",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except Exception as e:
|
|
logger.error(f"Error fetching release-group {mbid}: {e}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get full recording details by MusicBrainz ID
|
|
|
|
Args:
|
|
mbid: MusicBrainz ID of the recording
|
|
includes: Optional list of additional data to include
|
|
|
|
Returns:
|
|
Recording data or None if not found
|
|
"""
|
|
try:
|
|
params = {'fmt': 'json'}
|
|
if includes:
|
|
params['inc'] = '+'.join(includes)
|
|
|
|
response = self.session.get(
|
|
f"{self.BASE_URL}/recording/{mbid}",
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching recording {mbid}: {e}")
|
|
return None
|