Files
vrobbler/vrobbler/apps/music/musicbrainz.py

488 lines
16 KiB
Python

from datetime import datetime
import logging
import musicbrainzngs
from dateutil.parser import parse
logger = logging.getLogger(__name__)
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
release_dict = {}
release_data = musicbrainzngs.get_release_by_id(
musicbrainz_id,
includes=["artists", "release-groups", "recordings"],
).get("release")
if not release_data:
return release_dict
primary_artist = release_data.get("artist-credit")[0]
release_dict = {
"artist": {
"name": primary_artist.get("name"),
"musicbrainz_id": primary_artist.get("id"),
},
"album": {
"name": release_data.get("title"),
"musicbrainz_id": musicbrainz_id,
"musicbrainz_releasegroup_id": release_data.get(
"release-group"
).get("id"),
"musicbrainz_albumaritist_id": primary_artist.get("id"),
"year": release_data.get("year")[0:4],
},
}
release_dict["tracks"] = []
for track in release_data.get("medium-list")[0]["track-list"]:
recording = track["recording"]
release_dict["tracks"].append(
{
"title": recording["title"],
"musicbrainz_id": recording["id"],
"run_time": track["length"] / 1000,
}
)
return release_dict
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
top_result = {}
try:
top_result = musicbrainzngs.search_releases(
release_name, artist=artist_name
)["release-list"][0]
except IndexError:
logger.info(
f"No release found on MB for {artist_name} and {release_name}"
)
score = int(top_result.get("ext:score", 0))
if score < 85:
logger.debug(
"Album lookup score below 85 threshold",
extra={"result": top_result},
)
return {}
year = None
if top_result.get("date"):
year = parse(top_result["date"]).year
return {
"year": year,
"title": top_result["title"],
"mb_id": top_result["id"],
"mb_group_id": top_result["release-group"]["id"],
}
def lookup_artist_from_mb(artist_name: str) -> dict:
try:
top_result = musicbrainzngs.search_artists(artist=artist_name)[
"artist-list"
][0]
except IndexError:
return {}
score = int(top_result.get("ext:score"))
if score < 85:
logger.debug(
"Artist lookup score below 85 threshold",
extra={"result": top_result},
)
return {}
return top_result
def lookup_track_from_mb(
track_name: str, artist_mb_id: str, album_mb_id: str = ""
) -> dict:
logger.info(
"[lookup_track_from_mb] called",
extra={
"track_name": track_name,
"artist_mb_id": artist_mb_id,
"album_mb_id": album_mb_id,
},
)
try:
results = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mb_id, release=album_mb_id
)["recording-list"]
logger.info(
"[lookup_track_from_mb] musicbrainz recordings search results",
extra={"results": results},
)
top_result = results[0]
except IndexError:
logger.error("[lookup_track_from_mb] no results found")
return {}
score = int(top_result.get("ext:score"))
if score < 85:
logger.info(
"[lookup_track_from_mb] no results above 85% certainty ",
extra={"results": results},
)
return {}
return top_result
def get_album_metadata(album_name, artist_name, strict=True) -> dict:
"""
Get detailed metadata for an album from MusicBrainz.
:param album_name: Name of the album
:param artist_name: Name of the artist
:param strict: If True, only exact matches on album and artist (case-insensitive)
:return: dict with album metadata, or None if not found
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=5
)
for release in result.get("release-list", []):
title = release["title"]
primary_artist = release["artist-credit"][0]["artist"]["name"]
title_match = title.lower() == album_name.lower()
artist_match = primary_artist.lower() == artist_name.lower()
if not strict or (title_match and artist_match):
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if isinstance(ac, dict) and "artist" in ac
]
return {
"album_title": title,
"primary_artist": primary_artist,
"all_artists": all_artists,
"mbid": release["id"],
"release_date": release.get(
"date"
), # May be partial (e.g., just year)
"release_group_mbid": release["release-group"]["id"],
}
return {}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return {}
def get_recording_mbid_exact(
track_title: str, artist_name: str, album_name: str
) -> tuple[str, int]:
try:
result = musicbrainzngs.search_releases(
artist=artist_name, release=album_name, limit=1
)
releases = result.get("release-list", [])
if not releases:
raise Exception("No releases found")
release_id = releases[0]["id"]
release_data = musicbrainzngs.get_release_by_id(
release_id, includes=["recordings"]
)
tracks = release_data["release"]["medium-list"][0]["track-list"]
for track in tracks:
if track["recording"]["title"].lower() == track_title.lower():
return track["recording"]["id"], int(
track["recording"]["length"]
)
raise Exception("No recording found")
except musicbrainzngs.WebServiceError as e:
print(f"MusicBrainz error: {e}")
raise Exception(e)
def get_artist_metadata_extended(artist_name, strict=True):
"""
Fetch artist metadata including MBID, name, origin, tags, and description.
:param artist_name: The artist's name
:param strict: If True, only return exact name match
:return: dict with metadata, or None if not found
"""
try:
# Step 1: Search for artist
search_results = musicbrainzngs.search_artists(
artist=artist_name, limit=5
)
for artist in search_results.get("artist-list", []):
if not strict or artist["name"].lower() == artist_name.lower():
mbid = artist["id"]
# Step 2: Get detailed info about the artist
details = musicbrainzngs.get_artist_by_id(
mbid, includes=["tags", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
# Step 3: Try to find a Wikipedia or Wikidata link
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata":
description_url = rel["target"]
return {
"mbid": mbid,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url, # user can fetch summary if needed
}
return None
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return None
def get_artist_metadata_brief(artist_id):
"""Fetch basic artist metadata by MBID."""
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def parse_date(date_str):
"""Parse MusicBrainz date format into sortable datetime object."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def get_album_metadata_with_artist(album_name, artist_name, strict=True):
"""
Get metadata for the earliest release of an album and its primary artist.
:param album_name: Album title
:param artist_name: Name of the artist
:param strict: If True, enforce exact match for album and artist
:return: dict with album and primary artist metadata
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=100
)
query_album = album_name.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_releases = []
for release in result.get("release-list", []):
release_title = release["title"].strip()
primary_artist = release["artist-credit"][0]["artist"]
artist_name_actual = primary_artist["name"].strip()
if strict:
if release_title.casefold() != query_album:
continue
if artist_name_actual.casefold() != query_artist:
continue
release_date = parse_date(release.get("date"))
valid_releases.append((release, release_date))
if not valid_releases:
return None
# Sort releases by earliest release date
valid_releases.sort(key=lambda x: x[1] or datetime.max)
release, _ = valid_releases[0]
primary_artist = release["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"album_title": release["title"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"mbid": release["id"],
"release_group_mbid": release["release-group"]["id"],
"release_date": release.get("date"),
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (album lookup):", e)
return None
def get_artist_metadata_brief(artist_id):
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def get_track_metadata_with_artist(track_title, artist_name, strict=True):
"""
Get metadata for the earliest-known recording of a track, including artist info.
:param track_title: Track title
:param artist_name: Artist name
:param strict: If True, match exactly (case-insensitive)
:return: dict with track + release + artist metadata
"""
try:
result = musicbrainzngs.search_recordings(
recording=track_title, artist=artist_name, limit=100
)
query_track = track_title.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_candidates = []
for recording in result.get("recording-list", []):
rec_title = recording["title"].strip()
artist_credit = recording["artist-credit"][0]["artist"]
artist_name_actual = artist_credit["name"].strip()
if strict:
if rec_title.casefold() != query_track:
continue
if artist_name_actual.casefold() != query_artist:
continue
if "release-list" not in recording:
continue
for release in recording["release-list"]:
release_date = parse_date(release.get("date"))
if release_date:
valid_candidates.append(
(recording["id"], release, release_date)
)
if not valid_candidates:
return None
# Pick the earliest release
valid_candidates.sort(key=lambda x: x[2])
recording_id, release, _ = valid_candidates[0]
# Fetch full recording info
full_recording = musicbrainzngs.get_recording_by_id(
recording_id, includes=["artists", "releases"]
)["recording"]
primary_artist = full_recording["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in full_recording["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"track_title": full_recording["title"],
"length_ms": full_recording.get("length"),
"recording_mbid": recording_id,
"release_title": release["title"],
"release_date": release.get("date"),
"release_group_mbid": release["release-group"]["id"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (track lookup):", e)
return None