488 lines
16 KiB
Python
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
|