From 475ee94c6f99ca236ccdfbfc365d428f8bbac20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:16:25 +0100 Subject: [PATCH 01/12] Move TimeoutSession under beetsplug._utils --- beetsplug/_utils/requests.py | 38 ++++++++++++++++++++++++++++++++++++ beetsplug/lyrics.py | 35 ++------------------------------- 2 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 beetsplug/_utils/requests.py diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py new file mode 100644 index 0000000000..8001b6895a --- /dev/null +++ b/beetsplug/_utils/requests.py @@ -0,0 +1,38 @@ +import atexit +import importlib +from http import HTTPStatus + +import requests + + +class NotFoundError(requests.exceptions.HTTPError): + pass + + +class CaptchaError(requests.exceptions.HTTPError): + pass + + +class TimeoutSession(requests.Session): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + beets_version = importlib.metadata.version("beets") + self.headers["User-Agent"] = f"beets/{beets_version} https://beets.io/" + + @atexit.register + def close_session(): + """Close the requests session on shut down.""" + self.close() + + def request(self, *args, **kwargs): + """Wrap the request method to raise an exception on HTTP errors.""" + kwargs.setdefault("timeout", 10) + r = super().request(*args, **kwargs) + if r.status_code == HTTPStatus.NOT_FOUND: + raise NotFoundError("HTTP Error: Not Found", response=r) + if 300 <= r.status_code < 400: + raise CaptchaError("Captcha is required", response=r) + + r.raise_for_status() + + return r diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index d245d6a14c..716590a00b 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -16,7 +16,6 @@ from __future__ import annotations -import atexit import itertools import math import re @@ -25,7 +24,6 @@ from dataclasses import dataclass from functools import cached_property, partial, total_ordering from html import unescape -from http import HTTPStatus from itertools import groupby from pathlib import Path from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple @@ -41,6 +39,8 @@ from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices +from ._utils.requests import TimeoutSession + if TYPE_CHECKING: from beets.importer import ImportTask from beets.library import Item, Library @@ -54,41 +54,10 @@ TranslatorAPI, ) -USER_AGENT = f"beets/{beets.__version__}" INSTRUMENTAL_LYRICS = "[Instrumental]" -class NotFoundError(requests.exceptions.HTTPError): - pass - - -class CaptchaError(requests.exceptions.HTTPError): - pass - - -class TimeoutSession(requests.Session): - def request(self, *args, **kwargs): - """Wrap the request method to raise an exception on HTTP errors.""" - kwargs.setdefault("timeout", 10) - r = super().request(*args, **kwargs) - if r.status_code == HTTPStatus.NOT_FOUND: - raise NotFoundError("HTTP Error: Not Found", response=r) - if 300 <= r.status_code < 400: - raise CaptchaError("Captcha is required", response=r) - - r.raise_for_status() - - return r - - r_session = TimeoutSession() -r_session.headers.update({"User-Agent": USER_AGENT}) - - -@atexit.register -def close_session(): - """Close the requests session on shut down.""" - r_session.close() # Utilities. From 15f3a74afe2f1cfd77a424da7d94d3868160338b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:17:43 +0100 Subject: [PATCH 02/12] Define MusicBrainzAPI class with rate limiting --- beetsplug/musicbrainz.py | 21 +++++++++++++++++++++ poetry.lock | 35 ++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b9..9a1bef80f0 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -26,6 +26,7 @@ import musicbrainzngs from confuse.exceptions import NotFoundError +from requests_ratelimiter import LimiterMixin import beets import beets.autotag.hooks @@ -33,6 +34,8 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id +from ._utils.requests import TimeoutSession + if TYPE_CHECKING: from typing import Literal @@ -55,6 +58,11 @@ "year": "date", } + +class LimiterTimeoutSession(LimiterMixin, TimeoutSession): + pass + + musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") @@ -118,6 +126,19 @@ def get_message(self): BROWSE_MAXTRACKS = 500 +class MusicBrainzAPI: + api_url = "https://musicbrainz.org/ws/2/" + + @cached_property + def session(self) -> LimiterTimeoutSession: + return LimiterTimeoutSession(per_second=1) + + def _get(self, entity: str, **kwargs) -> JSONDict: + return self.session.get( + f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} + ).json() + + def _preferred_alias(aliases: list[JSONDict]): """Given an list of alias structures for an artist credit, select and return the user's preferred alias alias or None if no matching diff --git a/poetry.lock b/poetry.lock index 6f0523a42f..ea32a959c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2392,6 +2392,21 @@ docs = ["sphinx", "sphinx_rtd_theme"] fuzzer = ["atheris", "hypothesis"] test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout"] +[[package]] +name = "pyrate-limiter" +version = "2.10.0" +description = "Python Rate-Limiter using Leaky-Bucket Algorithm" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pyrate_limiter-2.10.0-py3-none-any.whl", hash = "sha256:a99e52159f5ed5eb58118bed8c645e30818e7c0e0d127a0585c8277c776b0f7f"}, + {file = "pyrate_limiter-2.10.0.tar.gz", hash = "sha256:98cc52cdbe058458e945ae87d4fd5a73186497ffa545ee6e98372f8599a5bd34"}, +] + +[package.extras] +all = ["filelock (>=3.0)", "redis (>=3.3,<4.0)", "redis-py-cluster (>=2.1.3,<3.0.0)"] +docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0,<5.0.0)", "sphinx-autodoc-typehints (>=1.17,<2.0)", "sphinx-copybutton (>=0.5)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] + [[package]] name = "pytest" version = "8.4.1" @@ -2883,6 +2898,24 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-ratelimiter" +version = "0.7.0" +description = "Rate-limiting for the requests library" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "requests_ratelimiter-0.7.0-py3-none-any.whl", hash = "sha256:1a7ef2faaa790272722db8539728690046237766fcc479f85b9591e5356a8185"}, + {file = "requests_ratelimiter-0.7.0.tar.gz", hash = "sha256:a070c8a359a6f3a001b0ccb08f17228b7ae0a6e21d8df5b6f6bd58389cddde45"}, +] + +[package.dependencies] +pyrate-limiter = "<3.0" +requests = ">=2.20" + +[package.extras] +docs = ["furo (>=2023.3,<2024.0)", "myst-parser (>=1.0)", "sphinx (>=5.2,<6.0)", "sphinx-autodoc-typehints (>=1.22,<2.0)", "sphinx-copybutton (>=0.5)"] + [[package]] name = "resampy" version = "0.4.3" @@ -3672,4 +3705,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" +content-hash = "6200520d31b679288bf3ee360723a2d0b140b91b3068d322bc11b1aeecad5b96" diff --git a/pyproject.toml b/pyproject.toml index 3a355418cb..f9f1acb031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ musicbrainzngs = ">=0.4" numpy = ">=1.24.4" platformdirs = ">=3.5.0" pyyaml = "*" +requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" From 310f2fef0f37f41e5452ecbb3b088c13dd906752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:18:06 +0100 Subject: [PATCH 03/12] Add missing blame ignore revs from musicbrainz plugin --- .git-blame-ignore-revs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 14b50859f4..8afa862952 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -49,6 +49,10 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1 c490ac5810b70f3cf5fd8649669838e8fdb19f4d # Importer restructure 9147577b2b19f43ca827e9650261a86fb0450cef +# Move functionality under MusicBrainz plugin +529aaac7dced71266c6d69866748a7d044ec20ff +# musicbrainz: reorder methods +5dc6f45110b99f0cc8dbb94251f9b1f6d69583fa # Copy paste query, types from library to dbcore 1a045c91668c771686f4c871c84f1680af2e944b # Library restructure (split library.py into multiple modules) From c42db0265567b4f2f223d0a3ea6b2ea2e886d1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:22:58 +0100 Subject: [PATCH 04/12] Move pseudo release lookup under the plugin --- beetsplug/musicbrainz.py | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 9a1bef80f0..b7c439dfda 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -318,30 +318,6 @@ def _set_date_str( setattr(info, key, date_num) -def _is_translation(r): - _trans_key = "transl-tracklisting" - return r["type"] == _trans_key and r["direction"] == "backward" - - -def _find_actual_release_from_pseudo_release( - pseudo_rel: JSONDict, -) -> JSONDict | None: - try: - relations = pseudo_rel["release"]["release-relation-list"] - except KeyError: - return None - - # currently we only support trans(liter)ation's - translations = [r for r in relations if _is_translation(r)] - - if not translations: - return None - - actual_id = translations[0]["target"] - - return musicbrainzngs.get_release_by_id(actual_id, RELEASE_INCLUDES) - - def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo ) -> beets.autotag.hooks.AlbumInfo | None: @@ -876,8 +852,17 @@ def album_for_id( # resolve linked release relations actual_res = None - if res["release"].get("status") == "Pseudo-Release": - actual_res = _find_actual_release_from_pseudo_release(res) + if res.get("status") == "Pseudo-Release" and ( + relations := res["release"].get("release-relation-list") + ): + for rel in relations: + if ( + rel["type"] == "transl-tracklisting" + and rel["direction"] == "backward" + ): + actual_res = musicbrainzngs.get_release_by_id( + rel["target"], RELEASE_INCLUDES + ) except musicbrainzngs.ResponseError: self._log.debug("Album ID match failed.") From 0325b1273c2deb4f18e3f3062b0d5865c2dc7a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:19:34 +0100 Subject: [PATCH 05/12] musicbrainz: lookup release directly --- beetsplug/_utils/requests.py | 4 +- beetsplug/lyrics.py | 4 +- beetsplug/musicbrainz.py | 177 ++++++------ test/plugins/test_musicbrainz.py | 455 +++++++++++++++---------------- 4 files changed, 320 insertions(+), 320 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 8001b6895a..361fdd5c45 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -5,7 +5,7 @@ import requests -class NotFoundError(requests.exceptions.HTTPError): +class HTTPNotFoundError(requests.exceptions.HTTPError): pass @@ -29,7 +29,7 @@ def request(self, *args, **kwargs): kwargs.setdefault("timeout", 10) r = super().request(*args, **kwargs) if r.status_code == HTTPStatus.NOT_FOUND: - raise NotFoundError("HTTP Error: Not Found", response=r) + raise HTTPNotFoundError("HTTP Error: Not Found", response=r) if 300 <= r.status_code < 400: raise CaptchaError("Captcha is required", response=r) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 716590a00b..98dfa868a6 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -39,7 +39,7 @@ from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices -from ._utils.requests import TimeoutSession +from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession if TYPE_CHECKING: from beets.importer import ImportTask @@ -325,7 +325,7 @@ def fetch_candidates( yield self.fetch_json(self.SEARCH_URL, params=base_params) - with suppress(NotFoundError): + with suppress(HTTPNotFoundError): yield [self.fetch_json(self.GET_URL, params=get_params)] @classmethod diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b7c439dfda..2a6b9afc96 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -34,7 +34,7 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id -from ._utils.requests import TimeoutSession +from ._utils.requests import HTTPNotFoundError, TimeoutSession if TYPE_CHECKING: from typing import Literal @@ -81,26 +81,23 @@ def get_message(self): return f"{self._reasonstr()} in {self.verb} with query {self.query!r}" -RELEASE_INCLUDES = list( - { - "artists", - "media", - "recordings", - "release-groups", - "labels", - "artist-credits", - "aliases", - "recording-level-rels", - "work-rels", - "work-level-rels", - "artist-rels", - "isrcs", - "url-rels", - "release-rels", - "tags", - } - & set(musicbrainzngs.VALID_INCLUDES["release"]) -) +RELEASE_INCLUDES = [ + "artists", + "media", + "recordings", + "release-groups", + "labels", + "artist-credits", + "aliases", + "recording-level-rels", + "work-rels", + "work-level-rels", + "artist-rels", + "isrcs", + "url-rels", + "release-rels", + "tags", +] TRACK_INCLUDES = list( { @@ -127,7 +124,7 @@ def get_message(self): class MusicBrainzAPI: - api_url = "https://musicbrainz.org/ws/2/" + api_url = "https://musicbrainz.org/ws/2" @cached_property def session(self) -> LimiterTimeoutSession: @@ -138,6 +135,9 @@ def _get(self, entity: str, **kwargs) -> JSONDict: f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} ).json() + def get_release(self, id_: str) -> JSONDict: + return self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + def _preferred_alias(aliases: list[JSONDict]): """Given an list of alias structures for an artist credit, select @@ -162,7 +162,7 @@ def _preferred_alias(aliases: list[JSONDict]): for alias in valid_aliases: if ( alias["locale"] == locale - and "primary" in alias + and alias.get("primary") and alias.get("type", "").lower() not in ignored_alias_types ): matches.append(alias) @@ -185,36 +185,33 @@ def _multi_artist_credit( artist_sort_parts = [] artist_credit_parts = [] for el in credit: - if isinstance(el, str): - # Join phrase. - if include_join_phrase: - artist_parts.append(el) - artist_credit_parts.append(el) - artist_sort_parts.append(el) + 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: - alias = _preferred_alias(el["artist"].get("alias-list", ())) + artist_sort_parts.append(cur_artist_name) - # An artist. - if alias: - cur_artist_name = alias["alias"] - 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) - # 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, @@ -284,9 +281,9 @@ def _preferred_release_event( ].as_str_seq() for country in preferred_countries: - for event in release.get("release-event-list", {}): + for event in release.get("release-events", {}): try: - if country in event["area"]["iso-3166-1-code-list"]: + if country in event["area"]["iso-3166-1-codes"]: return country, event["date"] except KeyError: pass @@ -359,6 +356,10 @@ def _merge_pseudo_and_actual_album( class MusicBrainzPlugin(MetadataSourcePlugin): + @cached_property + def api(self) -> MusicBrainzAPI: + return MusicBrainzAPI() + def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -446,9 +447,9 @@ def track_info( info.artists_ids = _artist_ids(recording["artist-credit"]) info.artist_id = info.artists_ids[0] - if recording.get("artist-relation-list"): + if recording.get("relations"): info.remixer = _get_related_artist_names( - recording["artist-relation-list"], relation_type="remixer" + recording["relations"], relation_type="remixer" ) if recording.get("length"): @@ -462,7 +463,7 @@ def track_info( lyricist = [] composer = [] composer_sort = [] - for work_relation in recording.get("work-relation-list", ()): + for work_relation in recording.get("relations", ()): if work_relation["type"] != "performance": continue info.work = work_relation["work"]["title"] @@ -470,9 +471,7 @@ def track_info( if "disambiguation" in work_relation["work"]: info.work_disambig = work_relation["work"]["disambiguation"] - for artist_relation in work_relation["work"].get( - "artist-relation-list", () - ): + for artist_relation in work_relation["work"].get("relations", ()): if "type" in artist_relation: type = artist_relation["type"] if type == "lyricist": @@ -489,7 +488,7 @@ def track_info( info.composer_sort = ", ".join(composer_sort) arranger = [] - for artist_relation in recording.get("artist-relation-list", ()): + for artist_relation in recording.get("relations", ()): if "type" in artist_relation: type = artist_relation["type"] if type == "arranger": @@ -521,9 +520,9 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: release["artist-credit"], include_join_phrase=False ) - ntracks = sum(len(m["track-list"]) for m in release["medium-list"]) + ntracks = sum(len(m["tracks"]) for m in release["media"]) - # The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list' + # The MusicBrainz API omits 'relations' # when the release has more than 500 tracks. So we use browse_recordings # on chunks of tracks to recover the same information in this case. if ntracks > BROWSE_MAXTRACKS: @@ -540,27 +539,27 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: )["recording-list"] ) track_map = {r["id"]: r for r in recording_list} - for medium in release["medium-list"]: - for recording in medium["track-list"]: + for medium in release["media"]: + for recording in medium["tracks"]: recording_info = track_map[recording["recording"]["id"]] recording["recording"] = recording_info # Basic info. track_infos = [] index = 0 - for medium in release["medium-list"]: + for medium in release["media"]: disctitle = medium.get("title") format = medium.get("format") if format in config["match"]["ignored_media"].as_str_seq(): continue - all_tracks = medium["track-list"] + all_tracks = medium["tracks"] if ( - "data-track-list" in medium + "data-tracks" in medium and not config["match"]["ignore_data_tracks"] ): - all_tracks += medium["data-track-list"] + all_tracks += medium["data-tracks"] track_count = len(all_tracks) if "pregap" in medium: @@ -575,7 +574,7 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: if ( "video" in track["recording"] - and track["recording"]["video"] == "true" + and track["recording"]["video"] and config["match"]["ignore_video_tracks"] ): continue @@ -629,7 +628,7 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: artists=artists_names, artists_ids=album_artist_ids, tracks=track_infos, - mediums=len(release["medium-list"]), + mediums=len(release["media"]), artist_sort=artist_sort_name, artists_sort=artists_sort_names, artist_credit=artist_credit_name, @@ -669,9 +668,9 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: rel_primarytype = release["release-group"]["primary-type"] if rel_primarytype: albumtypes.append(rel_primarytype.lower()) - if "secondary-type-list" in release["release-group"]: - if release["release-group"]["secondary-type-list"]: - for sec_type in release["release-group"]["secondary-type-list"]: + if "secondary-types" in release["release-group"]: + if release["release-group"]["secondary-types"]: + for sec_type in release["release-group"]["secondary-types"]: albumtypes.append(sec_type.lower()) info.albumtypes = albumtypes @@ -687,8 +686,8 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: _set_date_str(info, release_group_date, True) # Label name. - if release.get("label-info-list"): - label_info = release["label-info-list"][0] + if release.get("label-info"): + label_info = release["label-info"][0] if label_info.get("label"): label = label_info["label"]["name"] if label != "[no label]": @@ -702,18 +701,18 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: info.language = rep.get("language") # Media (format). - if release["medium-list"]: + if release["media"]: # If all media are the same, use that medium name - if len({m.get("format") for m in release["medium-list"]}) == 1: - info.media = release["medium-list"][0].get("format") + if len({m.get("format") for m in release["media"]}) == 1: + info.media = release["media"][0].get("format") # Otherwise, let's just call it "Media" else: info.media = "Media" if self.config["genres"]: sources = [ - release["release-group"].get("tag-list", []), - release.get("tag-list", []), + release["release-group"].get("tags", []), + release.get("tags", []), ] genres: Counter[str] = Counter() for source in sources: @@ -729,11 +728,12 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: wanted_sources = { site for site, wanted in external_ids.items() if wanted } - if wanted_sources and (url_rels := release.get("url-relation-list")): + if wanted_sources and (rels := release.get("relations")): + url_rels = [r for r in rels if r["type"] == "streaming"] urls = {} for source, url in product(wanted_sources, url_rels): - if f"{source}.com" in (target := url["target"]): + if f"{source}.com" in (target := url["url"]["resource"]): urls[source] = target self._log.debug( "Found link to {} release via MusicBrainz", @@ -823,7 +823,10 @@ def candidates( criteria = self.get_album_criteria(items, artist, album, va_likely) release_ids = (r["id"] for r in self._search_api("release", criteria)) - yield from filter(None, map(self.album_for_id, release_ids)) + for id_ in release_ids: + with suppress(HTTPNotFoundError): + if album_info := self.album_for_id(id_): + yield album_info def item_candidates( self, item: Item, artist: str, title: str @@ -847,22 +850,20 @@ def album_for_id( return None try: - res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) + res = self.api.get_release(albumid) # resolve linked release relations actual_res = None if res.get("status") == "Pseudo-Release" and ( - relations := res["release"].get("release-relation-list") + relations := res.get("relations") ): for rel in relations: if ( rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): - actual_res = musicbrainzngs.get_release_by_id( - rel["target"], RELEASE_INCLUDES - ) + actual_res = self.api.get_release(rel["target"]) except musicbrainzngs.ResponseError: self._log.debug("Album ID match failed.") @@ -873,11 +874,11 @@ def album_for_id( ) # release is potentially a pseudo release - release = self.album_info(res["release"]) + release = self.album_info(res) # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self.album_info(actual_res["release"]) + actual_release = self.album_info(actual_res) return _merge_pseudo_and_actual_album(release, actual_release) else: return release diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 844b2ad4ef..9db7f57513 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -64,8 +64,8 @@ def _make_release( } ], "date": "3001", - "medium-list": [], - "label-info-list": [ + "media": [], + "label-info": [ { "catalog-number": "CATALOG NUMBER", "label": {"name": "LABEL NAME"}, @@ -81,7 +81,7 @@ def _make_release( } if multi_artist_credit: - release["artist-credit"].append(" & ") # add join phase + release["artist-credit"][0]["joinphrase"] = " & " release["artist-credit"].append( { "artist": { @@ -122,7 +122,7 @@ def _make_release( ] if multi_artist_credit: - track["artist-credit"].append(" & ") # add join phase + track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { @@ -146,11 +146,11 @@ def _make_release( "number": "A1", } data_track_list.append(data_track) - release["medium-list"].append( + release["media"].append( { "position": "1", - "track-list": track_list, - "data-track-list": data_track_list, + "tracks": track_list, + "data-tracks": data_track_list, "format": medium_format, "title": "MEDIUM TITLE", } @@ -186,7 +186,7 @@ def _make_track( } ] if multi_artist_credit: - track["artist-credit"].append(" & ") # add join phase + track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { @@ -198,7 +198,7 @@ def _make_track( } ) if remixer: - track["artist-relation-list"] = [ + track["relations"] = [ { "type": "remixer", "type-id": "RELATION TYPE ID", @@ -213,7 +213,7 @@ def _make_track( } ] if video: - track["video"] = "true" + track["video"] = True if disambiguation: track["disambiguation"] = disambiguation return track @@ -299,10 +299,10 @@ def test_parse_medium_numbers_two_mediums(self): "number": "A1", } ] - release["medium-list"].append( + release["media"].append( { "position": "2", - "track-list": second_track_list, + "tracks": second_track_list, } ) @@ -678,15 +678,15 @@ def _credit_dict(self, suffix=""): def _add_alias(self, credit_dict, suffix="", locale="", primary=False): alias = { - "alias": f"ALIAS{suffix}", + "name": f"ALIAS{suffix}", "locale": locale, "sort-name": f"ALIASSORT{suffix}", } if primary: alias["primary"] = "primary" - if "alias-list" not in credit_dict["artist"]: - credit_dict["artist"]["alias-list"] = [] - credit_dict["artist"]["alias-list"].append(alias) + if "aliases" not in credit_dict["artist"]: + credit_dict["artist"]["aliases"] = [] + credit_dict["artist"]["aliases"].append(alias) def test_single_artist(self): credit = [self._credit_dict()] @@ -703,7 +703,10 @@ def test_single_artist(self): assert c == ["CREDIT"] def test_two_artists(self): - credit = [self._credit_dict("a"), " AND ", self._credit_dict("b")] + credit = [ + {**self._credit_dict("a"), "joinphrase": " AND "}, + self._credit_dict("b"), + ] a, s, c = musicbrainz._flatten_artist_credit(credit) assert a == "NAMEa AND NAMEb" assert s == "SORTa AND SORTb" @@ -761,86 +764,84 @@ class MBLibraryTest(MusicBrainzTestCase): def test_follow_pseudo_releases(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [ - { - "type": "transl-tracklisting", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "direction": "backward", - } - ], - } + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "relations": [ + { + "type": "transl-tracklisting", + "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "direction": "backward", + } + ], }, { - "release": { - "title": "actual", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "status": "Official", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "original title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "country": "COUNTRY", - } + "title": "actual", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "status": "Official", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "original title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "country": "COUNTRY", }, ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country == "COUNTRY" @@ -848,44 +849,44 @@ def test_follow_pseudo_releases(self): def test_pseudo_releases_with_empty_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [], - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "relations": [], + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -893,43 +894,43 @@ def test_pseudo_releases_with_empty_links(self): def test_pseudo_releases_without_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -937,50 +938,50 @@ def test_pseudo_releases_without_links(self): def test_pseudo_releases_with_unsupported_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [ - { - "type": "remaster", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "direction": "backward", - } - ], - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "relations": [ + { + "type": "remaster", + "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "direction": "backward", + } + ], + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -1047,30 +1048,28 @@ def test_candidates(self, monkeypatch, mb): lambda *_, **__: {"release-list": [{"id": self.mbid}]}, ) monkeypatch.setattr( - "musicbrainzngs.get_release_by_id", + "beetsplug.musicbrainz.MusicBrainzAPI.get_release", lambda *_, **__: { - "release": { - "title": "hi", - "id": self.mbid, - "status": "status", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": self.RECORDING, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - {"artist": {"name": "some-artist", "id": "some-id"}} - ], - "release-group": {"id": "another-id"}, - } + "title": "hi", + "id": self.mbid, + "status": "status", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": self.RECORDING, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + {"artist": {"name": "some-artist", "id": "some-id"}} + ], + "release-group": {"id": "another-id"}, }, ) candidates = list(mb.candidates([], "hello", "there", False)) From fa3afede7be4fbf312202bd94026df05744759d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:24:10 +0100 Subject: [PATCH 06/12] musicbrainz: lookup recordings directly --- beetsplug/musicbrainz.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2a6b9afc96..4aeb62af46 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -99,16 +99,13 @@ def get_message(self): "tags", ] -TRACK_INCLUDES = list( - { - "artists", - "aliases", - "isrcs", - "work-level-rels", - "artist-rels", - } - & set(musicbrainzngs.VALID_INCLUDES["recording"]) -) +TRACK_INCLUDES = [ + "artists", + "aliases", + "isrcs", + "work-level-rels", + "artist-rels", +] BROWSE_INCLUDES = [ "artist-credits", @@ -138,6 +135,9 @@ def _get(self, entity: str, **kwargs) -> JSONDict: def get_release(self, id_: str) -> JSONDict: return self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + def get_recording(self, id_: str) -> JSONDict: + return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + def _preferred_alias(aliases: list[JSONDict]): """Given an list of alias structures for an artist credit, select @@ -457,8 +457,8 @@ def track_info( info.trackdisambig = recording.get("disambiguation") - if recording.get("isrc-list"): - info.isrc = ";".join(recording["isrc-list"]) + if recording.get("isrcs"): + info.isrc = ";".join(recording["isrcs"]) lyricist = [] composer = [] @@ -894,12 +894,12 @@ def track_for_id( return None try: - res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) - except musicbrainzngs.ResponseError: + res = self.api.get_recording(trackid) + except (HTTPNotFoundError, musicbrainzngs.ResponseError): self._log.debug("Track ID match failed.") return None except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, "get recording by ID", trackid, traceback.format_exc() ) - return self.track_info(res["recording"]) + return self.track_info(res) From 4ee13c72b3e5c4b4131d700cae51d313ed333dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:27:57 +0100 Subject: [PATCH 07/12] musicbrainz: search directly --- beetsplug/musicbrainz.py | 38 ++++++++++++++++++-------------- test/plugins/test_musicbrainz.py | 10 ++++----- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 4aeb62af46..915328b1bb 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -771,17 +771,20 @@ def extra_mb_field_by_tag(self) -> dict[str, str]: def get_album_criteria( self, items: Sequence[Item], artist: str, album: str, va_likely: bool ) -> dict[str, str]: - criteria = { - "release": album, - "alias": album, - "tracks": str(len(items)), - } | ({"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist}) + criteria = {"release": album} | ( + {"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist} + ) for tag, mb_field in self.extra_mb_field_by_tag.items(): - most_common, _ = util.plurality(i.get(tag) for i in items) - value = str(most_common) - if tag == "catalognum": - value = value.replace(" ", "") + if tag == "tracks": + value = str(len(items)) + elif tag == "alias": + value = album + else: + most_common, _ = util.plurality(i.get(tag) for i in items) + value = str(most_common) + if tag == "catalognum": + value = value.replace(" ", "") criteria[mb_field] = value @@ -798,20 +801,23 @@ def _search_api( using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - filters = { - k: _v for k, v in filters.items() if (_v := v.lower().strip()) - } + query = " AND ".join( + f'{k}:"{_v}"' + for k, v in filters.items() + if (_v := v.lower().strip()) + ) self._log.debug( - "Searching for MusicBrainz {}s with: {!r}", query_type, filters + "Searching for MusicBrainz {}s with: {!r}", query_type, query ) try: - method = getattr(musicbrainzngs, f"search_{query_type}s") - res = method(limit=self.config["search_limit"].get(), **filters) + res = self.api._get( + query_type, query=query, limit=self.config["search_limit"].get() + ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, f"{query_type} search", filters, traceback.format_exc() ) - return res[f"{query_type}-list"] + return res[f"{query_type}s"] def candidates( self, diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 9db7f57513..7c40142eec 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -1026,15 +1026,13 @@ def test_get_album_criteria( assert mb.get_album_criteria(items, "Artist ", " Album", va_likely) == { "release": " Album", - "alias": " Album", - "tracks": str(len(items)), **expected_additional_criteria, } def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "musicbrainzngs.search_recordings", - lambda *_, **__: {"recording-list": [self.RECORDING]}, + "beetsplug.musicbrainz.MusicBrainzAPI._get", + lambda *_, **__: {"recordings": [self.RECORDING]}, ) candidates = list(mb.item_candidates(Item(), "hello", "there")) @@ -1044,8 +1042,8 @@ def test_item_candidates(self, monkeypatch, mb): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "musicbrainzngs.search_releases", - lambda *_, **__: {"release-list": [{"id": self.mbid}]}, + "beetsplug.musicbrainz.MusicBrainzAPI._get", + lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( "beetsplug.musicbrainz.MusicBrainzAPI.get_release", From 707e4ad929fbf99e2392e2252ed68e09228e4a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:55:04 +0100 Subject: [PATCH 08/12] musicbrainz: browse directly --- beetsplug/musicbrainz.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 915328b1bb..e0d75b2c0c 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -114,8 +114,6 @@ def get_message(self): "recording-rels", "release-rels", ] -if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES["recording"]: - BROWSE_INCLUDES.append("work-level-rels") BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 @@ -138,6 +136,11 @@ def get_release(self, id_: str) -> JSONDict: def get_recording(self, id_: str) -> JSONDict: return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + def browse_recordings(self, **kwargs) -> list[JSONDict]: + kwargs.setdefault("limit", BROWSE_CHUNKSIZE) + kwargs.setdefault("inc", BROWSE_INCLUDES) + return self._get("recording", **kwargs)["recordings"] + def _preferred_alias(aliases: list[JSONDict]): """Given an list of alias structures for an artist credit, select @@ -531,12 +534,7 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: for i in range(0, ntracks, BROWSE_CHUNKSIZE): self._log.debug("Retrieving tracks starting at {}", i) recording_list.extend( - musicbrainzngs.browse_recordings( - release=release["id"], - limit=BROWSE_CHUNKSIZE, - includes=BROWSE_INCLUDES, - offset=i, - )["recording-list"] + self.api.browse_recordings(release=release["id"], offset=i) ) track_map = {r["id"]: r for r in recording_list} for medium in release["media"]: From b46978887aa3d65319bad48e72af8fd3a1f64997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 00:20:47 +0100 Subject: [PATCH 09/12] musicbrainz: access the custom server directly, if configured --- beetsplug/musicbrainz.py | 31 +++++++++++++++++-------------- docs/plugins/musicbrainz.rst | 16 ++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index e0d75b2c0c..2c36d70631 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -19,6 +19,7 @@ import traceback from collections import Counter from contextlib import suppress +from dataclasses import dataclass from functools import cached_property from itertools import product from typing import TYPE_CHECKING, Any, Iterable, Sequence @@ -118,16 +119,18 @@ def get_message(self): BROWSE_MAXTRACKS = 500 +@dataclass class MusicBrainzAPI: - api_url = "https://musicbrainz.org/ws/2" + api_host: str + rate_limit: float @cached_property def session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=1) + return LimiterTimeoutSession(per_second=self.rate_limit) def _get(self, entity: str, **kwargs) -> JSONDict: return self.session.get( - f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} + f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"} ).json() def get_release(self, id_: str) -> JSONDict: @@ -361,7 +364,17 @@ def _merge_pseudo_and_actual_album( class MusicBrainzPlugin(MetadataSourcePlugin): @cached_property def api(self) -> MusicBrainzAPI: - return MusicBrainzAPI() + hostname = self.config["host"].as_str() + if hostname == "musicbrainz.org": + hostname, rate_limit = "https://musicbrainz.org", 1.0 + else: + https = self.config["https"].get(bool) + hostname = f"http{'s' if https else ''}://{hostname}" + rate_limit = ( + self.config["ratelimit"].get(int) + / self.config["ratelimit_interval"].as_number() + ) + return MusicBrainzAPI(hostname, rate_limit) def __init__(self): """Set up the python-musicbrainz-ngs module according to settings @@ -394,16 +407,6 @@ def __init__(self): "'musicbrainz.searchlimit' option is deprecated and will be " "removed in 3.0.0. Use 'musicbrainz.search_limit' instead." ) - hostname = self.config["host"].as_str() - https = self.config["https"].get(bool) - # Only call set_hostname when a custom server is configured. Since - # musicbrainz-ngs connects to musicbrainz.org with HTTPS by default - if hostname != "musicbrainz.org": - musicbrainzngs.set_hostname(hostname, https) - musicbrainzngs.set_rate_limit( - self.config["ratelimit_interval"].as_number(), - self.config["ratelimit"].get(int), - ) def track_info( self, diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 5ac2873685..947060e1f5 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -56,16 +56,12 @@ You can instruct beets to use `your own MusicBrainz database ratelimit: 100 The ``host`` key, of course, controls the Web server hostname (and port, -optionally) that will be contacted by beets (default: musicbrainz.org). The -``https`` key makes the client use HTTPS instead of HTTP. This setting applies -only to custom servers. The official MusicBrainz server always uses HTTPS. -(Default: no.) The server must have search indices enabled (see `Building search -indexes`_). - -The ``ratelimit`` option, an integer, controls the number of Web service -requests per second (default: 1). **Do not change the rate limit setting** if -you're using the main MusicBrainz server---on this public server, you're -limited_ to one request per second. +optionally) that will be contacted by beets. The ``https`` key makes the client +use HTTPS instead of HTTP. The server must have search indices enabled (see +`Building search indexes`_). The ``ratelimit`` option, an integer, controls the +number of Web service requests per second. + +If you use the public MusicBrainz server, these settings will be ignored. .. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup From 6ef93590076a2b6d990fc51b4dc668377e0fc6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 00:30:36 +0100 Subject: [PATCH 10/12] musicbrainz: remove error handling --- beetsplug/musicbrainz.py | 79 ++++++++++------------------------------ 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2c36d70631..b9da894c0b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,7 +16,6 @@ from __future__ import annotations -import traceback from collections import Counter from contextlib import suppress from dataclasses import dataclass @@ -25,7 +24,6 @@ from typing import TYPE_CHECKING, Any, Iterable, Sequence from urllib.parse import urljoin -import musicbrainzngs from confuse.exceptions import NotFoundError from requests_ratelimiter import LimiterMixin @@ -64,24 +62,6 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutSession): pass -musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") - - -class MusicBrainzAPIError(util.HumanReadableError): - """An error while talking to MusicBrainz. The `query` field is the - parameter to the action and may have any type. - """ - - def __init__(self, reason, verb, query, tb=None): - self.query = query - if isinstance(reason, musicbrainzngs.WebServiceError): - reason = "MusicBrainz not reachable" - super().__init__(reason, verb, tb) - - def get_message(self): - return f"{self._reasonstr()} in {self.verb} with query {self.query!r}" - - RELEASE_INCLUDES = [ "artists", "media", @@ -810,15 +790,9 @@ def _search_api( self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - try: - res = self.api._get( - query_type, query=query, limit=self.config["search_limit"].get() - ) - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, f"{query_type} search", filters, traceback.format_exc() - ) - return res[f"{query_type}s"] + return self.api._get( + query_type, query=query, limit=self.config["search_limit"].get() + )[f"{query_type}s"] def candidates( self, @@ -856,29 +830,20 @@ def album_for_id( self._log.debug("Invalid MBID ({}).", album_id) return None - try: - res = self.api.get_release(albumid) + res = self.api.get_release(albumid) - # resolve linked release relations - actual_res = None + # resolve linked release relations + actual_res = None - if res.get("status") == "Pseudo-Release" and ( - relations := res.get("relations") - ): - for rel in relations: - if ( - rel["type"] == "transl-tracklisting" - and rel["direction"] == "backward" - ): - actual_res = self.api.get_release(rel["target"]) - - except musicbrainzngs.ResponseError: - self._log.debug("Album ID match failed.") - return None - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, "get release by ID", albumid, traceback.format_exc() - ) + if res.get("status") == "Pseudo-Release" and ( + relations := res.get("relations") + ): + for rel in relations: + if ( + rel["type"] == "transl-tracklisting" + and rel["direction"] == "backward" + ): + actual_res = self.api.get_release(rel["target"]) # release is potentially a pseudo release release = self.album_info(res) @@ -900,13 +865,7 @@ def track_for_id( self._log.debug("Invalid MBID ({}).", track_id) return None - try: - res = self.api.get_recording(trackid) - except (HTTPNotFoundError, musicbrainzngs.ResponseError): - self._log.debug("Track ID match failed.") - return None - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, "get recording by ID", trackid, traceback.format_exc() - ) - return self.track_info(res) + with suppress(HTTPNotFoundError): + return self.track_info(self.api.get_recording(trackid)) + + return None From 065363c207778e458663c3539427a587064761ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 11:38:13 +0100 Subject: [PATCH 11/12] Make musicbrainzngs dependency optional and requests required --- .github/workflows/ci.yaml | 4 ++-- docs/plugins/listenbrainz.rst | 9 ++++----- docs/plugins/mbcollection.rst | 15 ++++++++++++--- docs/plugins/missing.rst | 15 ++++++++++++--- docs/plugins/parentwork.rst | 11 +++++++++-- poetry.lock | 16 ++++++++++------ pyproject.toml | 8 ++++++-- 7 files changed, 55 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80826f4685..dbc1d2fbdf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -58,7 +58,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage run: | - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} @@ -66,7 +66,7 @@ jobs: env: LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }} run: | - poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate + poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork poe docs poe test-with-coverage diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst index 21629ab54c..17926e878a 100644 --- a/docs/plugins/listenbrainz.rst +++ b/docs/plugins/listenbrainz.rst @@ -9,13 +9,12 @@ service. Installation ------------ -To enable the ListenBrainz plugin, add the following to your beets configuration -file (config.yaml_): +To use the ``listenbrainz`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``listenbrainz`` extra -.. code-block:: yaml +.. code-block:: bash - plugins: - - listenbrainz + pip install "beets[listenbrainz]" You can then configure the plugin by providing your Listenbrainz token (see intructions here_) and username: diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index 87efcd6d56..ffa86f3306 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -6,9 +6,18 @@ maintain your `music collection`_ list there. .. _music collection: https://musicbrainz.org/doc/Collections -To begin, just enable the ``mbcollection`` plugin in your configuration (see -:ref:`using-plugins`). Then, add your MusicBrainz username and password to your -:doc:`configuration file ` under a ``musicbrainz`` section: +Installation +------------ + +To use the ``mbcollection`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``mbcollection`` extra + +.. code-block:: bash + + pip install "beets[mbcollection]" + +Then, add your MusicBrainz username and password to your :doc:`configuration +file ` under a ``musicbrainz`` section: :: diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 10842933c2..f6962f337b 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -5,12 +5,21 @@ This plugin adds a new command, ``missing`` or ``miss``, which finds and lists missing tracks for albums in your collection. Each album requires one network call to album data source. +Installation +------------ + +To use the ``missing`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``missing`` extra + +.. code-block:: bash + + pip install "beets[missing]" + Usage ----- -Add the ``missing`` plugin to your configuration (see :ref:`using-plugins`). The -``beet missing`` command fetches album information from the origin data source -and lists names of the **tracks** that are missing from your library. +The ``beet missing`` command fetches album information from the origin data +source and lists names of the **tracks** that are missing from your library. It can also list the names of missing **albums** for each artist, although this is limited to albums from the MusicBrainz data source only. diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index 50c2c1ff01..e015bed684 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,8 +38,15 @@ This plugin adds seven tags: to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. -To use the ``parentwork`` plugin, enable it in your configuration (see -:ref:`using-plugins`). +Installation +------------ + +To use the ``parentwork`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``parentwork`` extra + +.. code-block:: bash + + pip install "beets[parentwork]" Configuration ------------- diff --git a/poetry.lock b/poetry.lock index ea32a959c0..0c9fb9ff67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1641,7 +1641,7 @@ type = ["mypy", "mypy-extensions"] name = "musicbrainzngs" version = "0.7.1" description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, @@ -2844,13 +2844,13 @@ files = [ [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] @@ -3691,9 +3691,13 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] +listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] +mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] +missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] +parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = ["PyGObject"] @@ -3705,4 +3709,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "6200520d31b679288bf3ee360723a2d0b140b91b3068d322bc11b1aeecad5b96" +content-hash = "c304e8c05cb33788a105b3fefdc0692d59e906ed4e8ef582c3fea7fd9f9a5d1d" diff --git a/pyproject.toml b/pyproject.toml index f9f1acb031..40efc8b924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,10 @@ confuse = ">=1.5.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" -musicbrainzngs = ">=0.4" numpy = ">=1.24.4" platformdirs = ">=3.5.0" pyyaml = "*" +requests = ">=2.32.5" requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" @@ -62,6 +62,7 @@ flask = { version = "*", optional = true } flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } librosa = { version = "^0.10.2.post1", optional = true } +musicbrainzngs = { version = ">=0.4", optional = true } mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } py7zr = { version = "*", optional = true } @@ -73,7 +74,6 @@ python3-discogs-client = { version = ">=2.3.15", optional = true } pyxdg = { version = "*", optional = true } rarfile = { version = "*", optional = true } reflink = { version = "*", optional = true } -requests = { version = "*", optional = true } resampy = { version = ">=0.4.3", optional = true } requests-oauthlib = { version = ">=0.6.1", optional = true } soco = { version = "*", optional = true } @@ -143,9 +143,13 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] +listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] +mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] +missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] +parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ From f30d96ee8f3472c6d9e0d089a7e98aaab790cebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 13 Oct 2025 09:46:19 +0100 Subject: [PATCH 12/12] Refactor HTTP request handling with RequestHandler base class Introduce a new RequestHandler base class to introduce a shared session, centralize HTTP request management and error handling across plugins. Key changes: - Add RequestHandler base class with a shared/cached session - Convert TimeoutSession to use SingletonMeta for proper resource management - Create LyricsRequestHandler subclass with lyrics-specific error handling - Update MusicBrainzAPI to inherit from RequestHandler --- beetsplug/_utils/requests.py | 139 +++++++++++++++++++++++++++---- beetsplug/lyrics.py | 40 ++++++--- beetsplug/musicbrainz.py | 31 +++---- test/plugins/test_musicbrainz.py | 4 +- 4 files changed, 170 insertions(+), 44 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 361fdd5c45..e0107b6206 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -1,38 +1,145 @@ +from __future__ import annotations + import atexit import importlib +import threading +from contextlib import contextmanager +from functools import cached_property from http import HTTPStatus +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar import requests +if TYPE_CHECKING: + from collections.abc import Iterator + + +class BeetsHTTPError(requests.exceptions.HTTPError): + STATUS: ClassVar[HTTPStatus] + + def __init__(self, *args, **kwargs) -> None: + super().__init__( + f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}", + *args, + **kwargs, + ) + + +class HTTPNotFoundError(BeetsHTTPError): + STATUS = HTTPStatus.NOT_FOUND + + +class Closeable(Protocol): + """Protocol for objects that have a close method.""" + + def close(self) -> None: ... + + +C = TypeVar("C", bound=Closeable) + + +class SingletonMeta(type, Generic[C]): + """Metaclass ensuring a single shared instance per class. + + Creates one instance per class type on first instantiation, reusing it + for all subsequent calls. Automatically registers cleanup on program exit + for proper resource management. + """ + + _instances: ClassVar[dict[type[Any], Any]] = {} + _lock: ClassVar[threading.Lock] = threading.Lock() -class HTTPNotFoundError(requests.exceptions.HTTPError): - pass + def __call__(cls, *args: Any, **kwargs: Any) -> C: + if cls not in cls._instances: + with cls._lock: + if cls not in SingletonMeta._instances: + instance = super().__call__(*args, **kwargs) + SingletonMeta._instances[cls] = instance + atexit.register(instance.close) + return SingletonMeta._instances[cls] -class CaptchaError(requests.exceptions.HTTPError): - pass +class TimeoutSession(requests.Session, metaclass=SingletonMeta): + """HTTP session with automatic timeout and status checking. + Extends requests.Session to provide sensible defaults for beets HTTP + requests: automatic timeout enforcement, status code validation, and + proper user agent identification. + """ -class TimeoutSession(requests.Session): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) beets_version = importlib.metadata.version("beets") self.headers["User-Agent"] = f"beets/{beets_version} https://beets.io/" - @atexit.register - def close_session(): - """Close the requests session on shut down.""" - self.close() - def request(self, *args, **kwargs): - """Wrap the request method to raise an exception on HTTP errors.""" + """Execute HTTP request with automatic timeout and status validation. + + Ensures all requests have a timeout (defaults to 10 seconds) and raises + an exception for HTTP error status codes. + """ kwargs.setdefault("timeout", 10) r = super().request(*args, **kwargs) - if r.status_code == HTTPStatus.NOT_FOUND: - raise HTTPNotFoundError("HTTP Error: Not Found", response=r) - if 300 <= r.status_code < 400: - raise CaptchaError("Captcha is required", response=r) - r.raise_for_status() return r + + +class RequestHandler: + """Manages HTTP requests with custom error handling and session management. + + Provides a reusable interface for making HTTP requests with automatic + conversion of standard HTTP errors to beets-specific exceptions. Supports + custom session types and error mappings that can be overridden by + subclasses. + """ + + session_type: ClassVar[type[TimeoutSession]] = TimeoutSession + explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ + HTTPNotFoundError + ] + + @cached_property + def session(self) -> Any: + """Lazily initialize and cache the HTTP session.""" + return self.session_type() + + def status_to_error( + self, code: int + ) -> type[requests.exceptions.HTTPError] | None: + """Map HTTP status codes to beets-specific exception types. + + Searches the configured explicit HTTP errors for a matching status code. + Returns None if no specific error type is registered for the given code. + """ + return next( + (e for e in self.explicit_http_errors if e.STATUS == code), None + ) + + @contextmanager + def handle_http_error(self) -> Iterator[None]: + """Convert standard HTTP errors to beets-specific exceptions. + + Wraps operations that may raise HTTPError, automatically translating + recognized status codes into their corresponding beets exception types. + Unrecognized errors are re-raised unchanged. + """ + try: + yield + except requests.exceptions.HTTPError as e: + if beets_error := self.status_to_error(e.response.status_code): + raise beets_error(response=e.response) from e + raise + + def request(self, method: str, *args, **kwargs): + """Perform HTTP request using the session with automatic error handling. + + Delegates to the underlying session method while converting recognized + HTTP errors to beets-specific exceptions through the error handler. + """ + with self.handle_http_error(): + return getattr(self.session, method)(*args, **kwargs) + + def fetch_json(self, *args, **kwargs): + """Fetch and parse JSON data from an HTTP endpoint.""" + return self.request("get", *args, **kwargs).json() diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 98dfa868a6..8aeb02ab0b 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -26,7 +26,7 @@ from html import unescape from itertools import groupby from pathlib import Path -from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple +from typing import TYPE_CHECKING, NamedTuple from urllib.parse import quote, quote_plus, urlencode, urlparse import langdetect @@ -34,14 +34,17 @@ from bs4 import BeautifulSoup from unidecode import unidecode -import beets from beets import plugins, ui from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices -from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession +from ._utils.requests import HTTPNotFoundError, RequestHandler if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + import confuse + from beets.importer import ImportTask from beets.library import Item, Library from beets.logging import BeetsLogger as Logger @@ -57,7 +60,9 @@ INSTRUMENTAL_LYRICS = "[Instrumental]" -r_session = TimeoutSession() +class CaptchaError(requests.exceptions.HTTPError): + def __init__(self, *args, **kwargs) -> None: + super().__init__("Captcha is required", *args, **kwargs) # Utilities. @@ -153,9 +158,18 @@ def slug(text: str) -> str: return re.sub(r"\W+", "-", unidecode(text).lower().strip()).strip("-") -class RequestHandler: +class LyricsRequestHandler(RequestHandler): _log: Logger + def status_to_error(self, code: int) -> type[requests.HTTPError] | None: + if err := super().status_to_error(code): + return err + + if 300 <= code < 400: + return CaptchaError + + return None + def debug(self, message: str, *args) -> None: """Log a debug message with the class name.""" self._log.debug(f"{self.__class__.__name__}: {message}", *args) @@ -185,7 +199,7 @@ def fetch_text( """ url = self.format_url(url, params) self.debug("Fetching HTML from {}", url) - r = r_session.get(url, **kwargs) + r = self.request("get", url, **kwargs) r.encoding = None return r.text @@ -193,13 +207,13 @@ def fetch_json(self, url: str, params: JSONDict | None = None, **kwargs): """Return JSON data from the given URL.""" url = self.format_url(url, params) self.debug("Fetching JSON from {}", url) - return r_session.get(url, **kwargs).json() + return super().fetch_json(url, **kwargs) def post_json(self, url: str, params: JSONDict | None = None, **kwargs): """Send POST request and return JSON response.""" url = self.format_url(url, params) self.debug("Posting JSON to {}", url) - return r_session.post(url, **kwargs).json() + return self.request("post", url, **kwargs).json() @contextmanager def handle_request(self) -> Iterator[None]: @@ -218,8 +232,10 @@ def name(cls) -> str: return cls.__name__.lower() -class Backend(RequestHandler, metaclass=BackendClass): - def __init__(self, config, log): +class Backend(LyricsRequestHandler, metaclass=BackendClass): + config: confuse.Subview + + def __init__(self, config: confuse.Subview, log: Logger) -> None: self._log = log self.config = config @@ -710,7 +726,7 @@ def scrape(cls, html: str) -> str | None: @dataclass -class Translator(RequestHandler): +class Translator(LyricsRequestHandler): TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate" LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$") SEPARATOR = " | " @@ -918,7 +934,7 @@ def write(self, items: list[Item]) -> None: ui.print_(textwrap.dedent(text)) -class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): +class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin): BACKEND_BY_NAME = { b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] } diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b9da894c0b..2be974f29f 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -34,6 +34,7 @@ from beets.util.id_extractors import extract_release_id from ._utils.requests import HTTPNotFoundError, TimeoutSession +from .lyrics import RequestHandler if TYPE_CHECKING: from typing import Literal @@ -58,10 +59,6 @@ } -class LimiterTimeoutSession(LimiterMixin, TimeoutSession): - pass - - RELEASE_INCLUDES = [ "artists", "media", @@ -99,30 +96,36 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutSession): BROWSE_MAXTRACKS = 500 +class LimiterTimeoutSession(LimiterMixin, TimeoutSession): + pass + + @dataclass -class MusicBrainzAPI: +class MusicBrainzAPI(RequestHandler): + session_type = LimiterTimeoutSession + api_host: str rate_limit: float @cached_property def session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=self.rate_limit) + return self.session_type(per_second=self.rate_limit) - def _get(self, entity: str, **kwargs) -> JSONDict: - return self.session.get( + def get_entity(self, entity: str, **kwargs) -> JSONDict: + return self.fetch_json( f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"} - ).json() + ) def get_release(self, id_: str) -> JSONDict: - return self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + return self.get_entity(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) def get_recording(self, id_: str) -> JSONDict: - return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + return self.get_entity(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) def browse_recordings(self, **kwargs) -> list[JSONDict]: kwargs.setdefault("limit", BROWSE_CHUNKSIZE) kwargs.setdefault("inc", BROWSE_INCLUDES) - return self._get("recording", **kwargs)["recordings"] + return self.get_entity("recording", **kwargs)["recordings"] def _preferred_alias(aliases: list[JSONDict]): @@ -149,7 +152,7 @@ def _preferred_alias(aliases: list[JSONDict]): if ( alias["locale"] == locale and alias.get("primary") - and alias.get("type", "").lower() not in ignored_alias_types + and (alias.get("type") or "").lower() not in ignored_alias_types ): matches.append(alias) @@ -790,7 +793,7 @@ def _search_api( self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - return self.api._get( + return self.api.get_entity( query_type, query=query, limit=self.config["search_limit"].get() )[f"{query_type}s"] diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 7c40142eec..20ec78b7ec 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -1031,7 +1031,7 @@ def test_get_album_criteria( def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI._get", + "beetsplug.musicbrainz.MusicBrainzAPI.fetch_json", lambda *_, **__: {"recordings": [self.RECORDING]}, ) @@ -1042,7 +1042,7 @@ def test_item_candidates(self, monkeypatch, mb): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI._get", + "beetsplug.musicbrainz.MusicBrainzAPI.fetch_json", lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr(