Files
SpotifyRecAlg/swingmusic/services/musicbrainz_client.py
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

355 lines
11 KiB
Python

"""
MusicBrainz API v2 Client for Universal Music Downloader
Provides comprehensive music metadata from MusicBrainz database
"""
import logging
from dataclasses import dataclass
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
@dataclass
class MusicBrainzRecording:
"""MusicBrainz recording metadata"""
mbid: str
title: str
artist: str
artist_mbid: str | None = None
release: str | None = None
release_mbid: str | None = None
isrc: str | None = None
duration: int | None = None
position: int | None = None
genres: list[str] = None
release_date: str | None = None
country: str | None = None
tags: list[str] = None
cover_art: str | None = None
@dataclass
class MusicBrainzArtist:
"""MusicBrainz artist metadata"""
mbid: str
name: str
sort_name: str | None = None
disambiguation: str | None = None
country: str | None = None
life_span: dict[str, str] | None = None
genres: list[str] = None
tags: list[str] = None
rating: float | None = None
class MusicBrainzClient:
"""MusicBrainz API v2 client"""
def __init__(self, app_name: str = "SwingMusic", app_version: str = "1.0.0"):
self.base_url = "https://musicbrainz.org/ws/2"
self.app_name = app_name
self.app_version = app_version
self.session = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session"""
if self.session is None:
self.session = aiohttp.ClientSession()
return self.session
def _build_url(self, endpoint: str, params: dict[str, str] = None) -> str:
"""Build MusicBrainz API URL"""
url = f"{self.base_url}/{endpoint}"
if params:
param_string = "&".join([f"{k}={v}" for k, v in params.items()])
url += f"?{param_string}"
return url
async def lookup_recording(
self, mbid: str, includes: list[str] = None
) -> MusicBrainzRecording | None:
"""Lookup detailed recording information"""
try:
session = await self._get_session()
params = {}
if includes:
params["inc"] = ",".join(includes)
url = self._build_url(f"recording/{mbid}", params)
headers = {
"User-Agent": f"{self.app_name}/{self.app_version}",
"Accept": "application/json",
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_recording_response(data)
else:
logger.warning(
f"MusicBrainz recording lookup failed: {response.status}"
)
return None
except Exception as e:
logger.error(f"Error looking up MusicBrainz recording: {e}")
return None
async def lookup_artist(
self, mbid: str, includes: list[str] = None
) -> MusicBrainzArtist | None:
"""Lookup detailed artist information"""
try:
session = await self._get_session()
params = {}
if includes:
params["inc"] = ",".join(includes)
url = self._build_url(f"artist/{mbid}", params)
headers = {
"User-Agent": f"{self.app_name}/{self.app_version}",
"Accept": "application/json",
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_artist_response(data)
else:
logger.warning(
f"MusicBrainz artist lookup failed: {response.status}"
)
return None
except Exception as e:
logger.error(f"Error looking up MusicBrainz artist: {e}")
return None
async def search_recordings(
self, query: str, artist: str = None, limit: int = 25
) -> list[MusicBrainzRecording]:
"""Search for recordings"""
try:
session = await self._get_session()
params = {"query": f'"{query}"', "limit": str(limit)}
if artist:
params["artist"] = f'"{artist}"'
url = self._build_url("recording", params)
headers = {
"User-Agent": f"{self.app_name}/{self.app_version}",
"Accept": "application/json",
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_recording_list_response(data)
else:
logger.warning(
f"MusicBrainz recording search failed: {response.status}"
)
return []
except Exception as e:
logger.error(f"Error searching MusicBrainz recordings: {e}")
return []
async def get_artist_releases(
self, mbid: str, release_types: list[str] = None
) -> list[str]:
"""Get all releases for an artist"""
try:
session = await self._get_session()
params = {}
if release_types:
params["type"] = ",".join(release_types)
url = self._build_url("release", {"artist": mbid, **params})
headers = {
"User-Agent": f"{self.app_name}/{self.app_version}",
"Accept": "application/json",
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
releases = data.get("releases", [])
return [release.get("id", "") for release in releases]
else:
logger.warning(
f"MusicBrainz artist releases failed: {response.status}"
)
return []
except Exception as e:
logger.error(f"Error getting MusicBrainz artist releases: {e}")
return []
def _parse_recording_response(
self, data: dict[str, Any]
) -> MusicBrainzRecording | None:
"""Parse MusicBrainz recording response"""
try:
recording_data = data.get("recording")
if not recording_data:
return None
# Extract basic info
title = recording_data.get("title", "")
# Extract artist info
artist_credit = recording_data.get("artist-credit", [])
artist = (
artist_credit[0].get("artist", {}).get("name", "")
if artist_credit
else ""
)
artist_mbid = (
artist_credit[0].get("artist", {}).get("id") if artist_credit else None
)
# Extract release info
release_list = recording_data.get("release-list", [])
release = release_list[0] if release_list else None
release_title = release.get("title", "") if release else None
release_mbid = release.get("id") if release else None
# Extract ISRC
isrc_list = recording_data.get("isrc-list", [])
isrc = isrc_list[0] if isrc_list else None
# Extract duration
duration = recording_data.get("length")
# Extract tags and genres
tag_list = recording_data.get("tag-list", [])
tags = [tag.get("name", "") for tag in tag_list]
# Extract release info
release_info = recording_data.get("release", {})
release_date = release_info.get("date")
country = release_info.get("country")
# Extract cover art
cover_art = None
if release:
cover_art_archive = release.get("cover-art-archive", [])
if cover_art_archive:
cover_art = cover_art_archive[0].get("image")
return MusicBrainzRecording(
mbid=data.get("id", ""),
title=title,
artist=artist,
artist_mbid=artist_mbid,
release=release_title,
release_mbid=release_mbid,
isrc=isrc,
duration=duration,
position=recording_data.get("position"),
genres=tags,
release_date=release_date,
country=country,
tags=tags,
cover_art=cover_art,
)
except Exception as e:
logger.error(f"Error parsing MusicBrainz recording response: {e}")
return None
def _parse_artist_response(self, data: dict[str, Any]) -> MusicBrainzArtist | None:
"""Parse MusicBrainz artist response"""
try:
artist_data = data.get("artist")
if not artist_data:
return None
name = artist_data.get("name", "")
sort_name = artist_data.get("sort-name")
disambiguation = artist_data.get("disambiguation")
country = artist_data.get("country")
# Extract life span
life_span = artist_data.get("life-span")
# Extract tags and genres
tag_list = artist_data.get("tag-list", [])
tags = [tag.get("name", "") for tag in tag_list]
# Extract rating
rating = artist_data.get("rating", {}).get("value")
return MusicBrainzArtist(
mbid=data.get("id", ""),
name=name,
sort_name=sort_name,
disambiguation=disambiguation,
country=country,
life_span=life_span,
genres=tags,
tags=tags,
rating=rating,
)
except Exception as e:
logger.error(f"Error parsing MusicBrainz artist response: {e}")
return None
def _parse_recording_list_response(
self, data: dict[str, Any]
) -> list[MusicBrainzRecording]:
"""Parse MusicBrainz recording list response"""
try:
recordings = []
recording_list = data.get("recordings", [])
for recording_data in recording_list:
recording = self._parse_recording_response(
{"recording": recording_data}
)
if recording:
recordings.append(recording)
return recordings
except Exception as e:
logger.error(f"Error parsing MusicBrainz recording list: {e}")
return []
async def close(self):
"""Close the aiohttp session"""
if self.session:
await self.session.close()
# Singleton instance for easy access
_musicbrainz_client: MusicBrainzClient | None = None
def get_musicbrainz_client() -> MusicBrainzClient:
"""Get or create the MusicBrainz client"""
global _musicbrainz_client
if _musicbrainz_client is None:
_musicbrainz_client = MusicBrainzClient()
return _musicbrainz_client
# Global instance
musicbrainz_client = MusicBrainzClient()