freedify/app/musicbrainz_service.py
2026-01-13 22:26:48 +00:00

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()