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