Files
beets_music_video/beetsplug/beets_music_videos/utils/mapping.py
David Freitag ea7d420975 Add beets_music_videos plugin for managing music videos
- Implemented a new beets plugin to import and manage music videos, supporting various video formats and providing metadata from IMVDB and MusicBrainz for autotagging.
- Added installation instructions and configuration options in README.md.
- Created IMVDBApi client for interacting with the IMVDB API.
- Defined typing for various API responses and utility functions for mapping MusicBrainz data to beets TrackInfo.
- Included desktop entry and JSON info files for a video titled "[SAST] The Evolution of Search-based Software Testing".
- Added utility functions for handling artist credits and related metadata.
- Introduced a grabbed.txt file for tracking video sources.
2026-03-18 18:51:27 -04:00

229 lines
7.2 KiB
Python

from __future__ import annotations
import beets
from urllib.parse import urljoin
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from beets.autotag.hooks import TrackInfo
from .._typing import JSONDict
MB_BASE_URL = "https://musicbrainz.org/"
def _artist_ids(credit: list[JSONDict]) -> list[str]:
"""
Given a list representing an ``artist-credit``,
return a list of artist IDs
"""
artist_ids: list[str] = []
for el in credit:
artist_ids.append(el["artist"]["id"])
return artist_ids
def _get_related_artist_names(relations: list[JSONDict], relation_type: str) -> str:
"""Given a list representing the artist relationships extract the names of
the remixers and concatenate them.
"""
related_artists: list[str] = []
for relation in relations:
if relation["type"] == relation_type:
related_artists.append(relation["artist"]["name"])
return ", ".join(related_artists)
def track_url(trackid: str) -> str:
return urljoin(MB_BASE_URL, f"recording/{trackid}")
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
) -> JSONDict | None:
"""Given a list of alias structures for an artist credit, select
and return the user's preferred alias or None if no matching
"""
if not aliases:
return None
# Only consider aliases that have locales set.
valid_aliases = [a for a in aliases if "locale" in a]
# Get any ignored alias types and lower case them to prevent case issues
ignored_alias_types = beets.config["import"]["ignored_alias_types"].as_str_seq()
ignored_alias_types = [a.lower() for a in ignored_alias_types]
# Search configured locales in order.
if languages is None:
languages = beets.config["import"]["languages"].as_str_seq()
for locale in languages:
# Find matching primary aliases for this locale that are not
# being ignored
matches: list[JSONDict] = []
for alias in valid_aliases:
if (
alias["locale"] == locale
and alias.get("primary")
and (alias.get("type") or "").lower() not in ignored_alias_types
):
matches.append(alias)
# Skip to the next locale if we have no matches
if not matches:
continue
return matches[0]
return None
def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]:
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
credit.
"""
artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit(
credit, include_join_phrase=True
)
return (
"".join(artist_parts),
"".join(artist_sort_parts),
"".join(artist_credit_parts),
)
def _multi_artist_credit(
credit: list[JSONDict], include_join_phrase: bool
) -> tuple[list[str], list[str], list[str]]:
"""Given a list representing an ``artist-credit`` block, accumulate
data into a triple of joined artist name lists: canonical, sort, and
credit.
"""
artist_parts: list[str] = []
artist_sort_parts: list[str] = []
artist_credit_parts: list[str] = []
for el in credit:
alias = _preferred_alias(el["artist"].get("aliases", ()))
# An artist.
if alias:
cur_artist_name = alias["name"]
else:
cur_artist_name = el["artist"]["name"]
artist_parts.append(cur_artist_name)
# Artist sort name.
if alias:
artist_sort_parts.append(alias["sort-name"])
elif "sort-name" in el["artist"]:
artist_sort_parts.append(el["artist"]["sort-name"])
else:
artist_sort_parts.append(cur_artist_name)
# Artist credit.
if "name" in el:
artist_credit_parts.append(el["name"])
else:
artist_credit_parts.append(cur_artist_name)
if include_join_phrase and (joinphrase := el.get("joinphrase")):
artist_parts.append(joinphrase)
artist_sort_parts.append(joinphrase)
artist_credit_parts.append(joinphrase)
return (
artist_parts,
artist_sort_parts,
artist_credit_parts,
)
def map_mb_to_trackinfo(
recording: JSONDict,
index: int | None = None,
medium: int | None = None,
medium_index: int | None = None,
medium_total: int | None = None,
) -> TrackInfo:
info = beets.autotag.hooks.TrackInfo(
title=recording["title"],
track_id=recording["id"],
index=index,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
data_source="musicbrainz",
data_url=track_url(recording["id"]),
)
info.trackdisambig = recording.get("disambiguation")
lyricist: list[str] = []
composer: list[str] = []
composer_sort: list[str] = []
if recording.get("artist-credit"):
(info.artist, info.artist_sort, info.artist_credit) = _flatten_artist_credit(
recording["artist-credit"]
)
(
info.artists,
info.artists_sort,
info.artists_credit,
) = _multi_artist_credit(recording["artist-credit"], include_join_phrase=False)
info.artists_ids = _artist_ids(recording["artist-credit"])
info.artist_id = info.artists_ids[0]
if recording.get("artist-relations"):
info.remixer = _get_related_artist_names(
recording["artist-relations"], relation_type="remixer"
)
if recording.get("length"):
info.length = int(recording["length"]) / 1000.0
if recording.get("isrcs"):
info.isrc = ";".join(recording["isrcs"])
for work_relation in recording.get("work-relations", ()):
if work_relation["type"] != "performance":
continue
info.work = work_relation["work"]["title"]
info.mb_workid = work_relation["work"]["id"]
if "disambiguation" in work_relation["work"]:
info.work_disambig = work_relation["work"]["disambiguation"]
for artist_relation in work_relation["work"].get("artist-relations", ()):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "lyricist":
lyricist.append(artist_relation["artist"]["name"])
elif type == "composer":
composer.append(artist_relation["artist"]["name"])
composer_sort.append(artist_relation["artist"]["sort-name"])
if lyricist:
info.lyricist = ", ".join(lyricist)
if composer:
info.composer = ", ".join(composer)
info.composer_sort = ", ".join(composer_sort)
arranger: list[str] = []
for artist_relation in recording.get("artist-relations", ()):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "arranger":
arranger.append(artist_relation["artist"]["name"])
if arranger:
info.arranger = ", ".join(arranger)
# Supplementary fields provided by plugins
extra_trackdatas = beets.plugins.send("mb_track_extract", data=recording)
for extra_trackdata in extra_trackdatas:
info.update(extra_trackdata)
return info