194 lines
6.7 KiB
Python
194 lines
6.7 KiB
Python
"""
|
|
MusicBrainz service for Freedify.
|
|
Provides metadata enrichment: release year, label, and cover art from Cover Art Archive.
|
|
"""
|
|
import httpx
|
|
from typing import Optional, Dict, Any
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MusicBrainzService:
|
|
"""Service for enriching track metadata from MusicBrainz."""
|
|
|
|
MB_API = "https://musicbrainz.org/ws/2"
|
|
CAA_API = "https://coverartarchive.org"
|
|
USER_AGENT = "Freedify/1.0 (https://github.com/freedify)"
|
|
|
|
def __init__(self):
|
|
self.client = httpx.AsyncClient(
|
|
timeout=15.0,
|
|
headers={"User-Agent": self.USER_AGENT}
|
|
)
|
|
|
|
async def lookup_recording(self, mbid: str) -> Optional[Dict[str, Any]]:
|
|
"""Look up a recording by MBID.
|
|
|
|
Returns:
|
|
{
|
|
'id': '...',
|
|
'name': 'Track Name',
|
|
'artists': 'Artist Name',
|
|
'album': 'Album Name',
|
|
'album_art': '...',
|
|
'release_date': '...',
|
|
'duration': '3:45'
|
|
}
|
|
"""
|
|
try:
|
|
response = await self.client.get(
|
|
f"{self.MB_API}/recording/{mbid}",
|
|
params={"fmt": "json", "inc": "releases+artist-credits+release-groups+genres"}
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.debug(f"MBID lookup failed: {mbid}")
|
|
return None
|
|
|
|
data = response.json()
|
|
|
|
# Helper to get artist name
|
|
artist_credit = data.get("artist-credit", [])
|
|
artist_name = ", ".join([ac.get("name", "") for ac in artist_credit]) if artist_credit else "Unknown Artist"
|
|
|
|
result = {
|
|
"id": mbid,
|
|
"name": data.get("title", "Unknown Track"),
|
|
"artists": artist_name,
|
|
"duration": data.get("length", 0) // 1000 if data.get("length") else 0
|
|
}
|
|
|
|
# Get release info
|
|
releases = data.get("releases", [])
|
|
if releases:
|
|
release = releases[0]
|
|
result["album"] = release.get("title", "")
|
|
result["release_date"] = release.get("date", "")
|
|
|
|
# Cover Art
|
|
release_id = release.get("id")
|
|
if release_id:
|
|
cover_url = await self._get_cover_art(release_id)
|
|
if cover_url:
|
|
result["album_art"] = cover_url
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"MusicBrainz recording lookup error: {e}")
|
|
return None
|
|
|
|
async def lookup_by_isrc(self, isrc: str) -> Optional[Dict[str, Any]]:
|
|
"""Look up a recording by ISRC and return enriched metadata.
|
|
|
|
Returns:
|
|
{
|
|
'release_date': '2020-01-15',
|
|
'label': 'Atlantic Records',
|
|
'cover_art_url': 'https://...',
|
|
'genres': ['pop', 'electronic'],
|
|
'release_id': '...' # for further lookups
|
|
}
|
|
"""
|
|
try:
|
|
# Skip non-standard ISRCs (like dz_ or ytm_ prefixed IDs)
|
|
if not isrc or isrc.startswith(('dz_', 'ytm_', 'LINK:')):
|
|
return None
|
|
|
|
logger.info(f"Looking up ISRC on MusicBrainz: {isrc}")
|
|
|
|
# Search for recording by ISRC
|
|
response = await self.client.get(
|
|
f"{self.MB_API}/isrc/{isrc}",
|
|
params={"fmt": "json", "inc": "releases+release-groups+labels+genres"}
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.debug(f"No MusicBrainz result for ISRC: {isrc}")
|
|
return None
|
|
|
|
data = response.json()
|
|
recordings = data.get("recordings", [])
|
|
|
|
if not recordings:
|
|
return None
|
|
|
|
# Get the first recording's release info
|
|
recording = recordings[0]
|
|
releases = recording.get("releases", [])
|
|
|
|
if not releases:
|
|
return None
|
|
|
|
# Use the first release (typically the original)
|
|
release = releases[0]
|
|
release_id = release.get("id", "")
|
|
|
|
result = {
|
|
"release_date": release.get("date", ""),
|
|
"release_id": release_id,
|
|
"label": "",
|
|
"cover_art_url": "",
|
|
"genres": []
|
|
}
|
|
|
|
# Get label from label-info
|
|
label_info = release.get("label-info", [])
|
|
if label_info and label_info[0].get("label"):
|
|
result["label"] = label_info[0]["label"].get("name", "")
|
|
|
|
# Get genres from recording
|
|
genres = recording.get("genres", [])
|
|
result["genres"] = [g.get("name", "") for g in genres[:5]]
|
|
|
|
# Try to get cover art from Cover Art Archive
|
|
if release_id:
|
|
cover_url = await self._get_cover_art(release_id)
|
|
if cover_url:
|
|
result["cover_art_url"] = cover_url
|
|
|
|
logger.info(f"MusicBrainz enrichment found: year={result['release_date']}, label={result['label']}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.debug(f"MusicBrainz lookup error for {isrc}: {e}")
|
|
return None
|
|
|
|
async def _get_cover_art(self, release_id: str) -> Optional[str]:
|
|
"""Get cover art URL from Cover Art Archive."""
|
|
try:
|
|
response = await self.client.get(
|
|
f"{self.CAA_API}/release/{release_id}",
|
|
follow_redirects=True
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
return None
|
|
|
|
data = response.json()
|
|
images = data.get("images", [])
|
|
|
|
# Get front cover, prefer large size
|
|
for img in images:
|
|
if img.get("front"):
|
|
# Prefer 500px version for quality/speed balance
|
|
thumbnails = img.get("thumbnails", {})
|
|
return thumbnails.get("500") or thumbnails.get("large") or img.get("image")
|
|
|
|
# Fallback to first image
|
|
if images:
|
|
return images[0].get("image")
|
|
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Cover Art Archive error: {e}")
|
|
return None
|
|
|
|
async def close(self):
|
|
"""Close the HTTP client."""
|
|
await self.client.aclose()
|
|
|
|
|
|
# Singleton instance
|
|
musicbrainz_service = MusicBrainzService()
|