From a33371b6efb4daddb1db59ccb3fd7479e7916626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 16:45:15 +0000 Subject: [PATCH] Migrate parentwork to use MusicBrainzAPI --- .github/workflows/ci.yaml | 4 +- beetsplug/_utils/musicbrainz.py | 3 + beetsplug/parentwork.py | 110 +++++++++++++++----------------- docs/plugins/parentwork.rst | 10 --- poetry.lock | 3 +- pyproject.toml | 1 - test/plugins/test_parentwork.py | 97 ++++++++++++++-------------- 7 files changed, 106 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 520a368ef..bfd05c718 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,7 +66,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 --extras=parentwork + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} @@ -74,7 +74,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 --extras=parentwork + poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe docs poe test-with-coverage diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 63ffd4aa3..cd58a8f54 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -91,6 +91,9 @@ class MusicBrainzAPI(RequestHandler): def get_recording(self, id_: str, **kwargs) -> JSONDict: return self.get_entity(f"recording/{id_}", **kwargs) + def get_work(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"work/{id_}", **kwargs) + def browse_recordings(self, **kwargs) -> list[JSONDict]: return self.get_entity("recording", **kwargs)["recordings"] diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index 6fa4bfbdb..15fcdefa8 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -20,50 +20,15 @@ from __future__ import annotations from typing import Any -import musicbrainzngs +import requests -from beets import __version__, ui +from beets import ui from beets.plugins import BeetsPlugin -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +from ._utils.musicbrainz import MusicBrainzAPIMixin -def find_parentwork_info(mb_workid: str) -> tuple[dict[str, Any], str | None]: - """Get the MusicBrainz information dict about a parent work, including - the artist relations, and the composition date for a work's parent work. - """ - work_date = None - - parent_id: str | None = mb_workid - - while parent_id: - current_id = parent_id - work_info = musicbrainzngs.get_work_by_id( - current_id, includes=["work-rels", "artist-rels"] - )["work"] - work_date = work_date or next( - ( - end - for a in work_info.get("artist-relation-list", []) - if a["type"] == "composer" and (end := a.get("end")) - ), - None, - ) - parent_id = next( - ( - w["work"]["id"] - for w in work_info.get("work-relation-list", []) - if w["type"] == "parts" and w["direction"] == "backward" - ), - None, - ) - - return musicbrainzngs.get_work_by_id( - current_id, includes=["artist-rels"] - ), work_date - - -class ParentWorkPlugin(BeetsPlugin): +class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): def __init__(self): super().__init__() @@ -125,14 +90,13 @@ class ParentWorkPlugin(BeetsPlugin): parentwork_info = {} composer_exists = False - if "artist-relation-list" in work_info["work"]: - for artist in work_info["work"]["artist-relation-list"]: - if artist["type"] == "composer": - composer_exists = True - parent_composer.append(artist["artist"]["name"]) - parent_composer_sort.append(artist["artist"]["sort-name"]) - if "end" in artist.keys(): - parentwork_info["parentwork_date"] = artist["end"] + for artist in work_info.get("artist-relations", []): + if artist["type"] == "composer": + composer_exists = True + parent_composer.append(artist["artist"]["name"]) + parent_composer_sort.append(artist["artist"]["sort-name"]) + if "end" in artist.keys(): + parentwork_info["parentwork_date"] = artist["end"] parentwork_info["parent_composer"] = ", ".join(parent_composer) parentwork_info["parent_composer_sort"] = ", ".join( @@ -144,16 +108,14 @@ class ParentWorkPlugin(BeetsPlugin): "no composer for {}; add one at " "https://musicbrainz.org/work/{}", item, - work_info["work"]["id"], + work_info["id"], ) - parentwork_info["parentwork"] = work_info["work"]["title"] - parentwork_info["mb_parentworkid"] = work_info["work"]["id"] + parentwork_info["parentwork"] = work_info["title"] + parentwork_info["mb_parentworkid"] = work_info["id"] - if "disambiguation" in work_info["work"]: - parentwork_info["parentwork_disambig"] = work_info["work"][ - "disambiguation" - ] + if "disambiguation" in work_info: + parentwork_info["parentwork_disambig"] = work_info["disambiguation"] else: parentwork_info["parentwork_disambig"] = None @@ -185,9 +147,9 @@ class ParentWorkPlugin(BeetsPlugin): work_changed = item.parentwork_workid_current != item.mb_workid if force or not hasparent or work_changed: try: - work_info, work_date = find_parentwork_info(item.mb_workid) - except musicbrainzngs.musicbrainz.WebServiceError as e: - self._log.debug("error fetching work: {}", e) + work_info, work_date = self.find_parentwork_info(item.mb_workid) + except requests.exceptions.RequestException: + self._log.debug("error fetching work", item, exc_info=True) return parent_info = self.get_info(item, work_info) parent_info["parentwork_workid_current"] = item.mb_workid @@ -228,3 +190,37 @@ class ParentWorkPlugin(BeetsPlugin): "parentwork_date", ], ) + + def find_parentwork_info( + self, mb_workid: str + ) -> tuple[dict[str, Any], str | None]: + """Get the MusicBrainz information dict about a parent work, including + the artist relations, and the composition date for a work's parent work. + """ + work_date = None + + parent_id: str | None = mb_workid + + while parent_id: + current_id = parent_id + work_info = self.mb_api.get_work( + current_id, includes=["work-rels", "artist-rels"] + ) + work_date = work_date or next( + ( + end + for a in work_info.get("artist-relations", []) + if a["type"] == "composer" and (end := a.get("end")) + ), + None, + ) + parent_id = next( + ( + w["work"]["id"] + for w in work_info.get("work-relations", []) + if w["type"] == "parts" and w["direction"] == "backward" + ), + None, + ) + + return work_info, work_date diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index e015bed68..21b774120 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,16 +38,6 @@ This plugin adds seven tags: to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. -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 60cbceebd..067fcf93c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4185,7 +4185,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = ["PyGObject"] @@ -4198,4 +4197,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "d9141a482e4990a4466a121a59deaeaf46e5613ff0af315f277110935e391e63" +content-hash = "dbe3785cbffd71f2ca758872f7654522228d6155c76a8f003bec22f03c8eada3" diff --git a/pyproject.toml b/pyproject.toml index ed0059610..658602484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 809387bbc..2218e9fd6 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,9 +14,6 @@ """Tests for the 'parentwork' plugin.""" -from typing import Any -from unittest.mock import Mock, patch - import pytest from beets.library import Item @@ -74,56 +71,56 @@ class ParentWorkIntegrationTest(PluginTestCase): assert item["mb_parentworkid"] == "XXX" -def mock_workid_response(mbid, includes): - works: list[dict[str, Any]] = [ - { - "id": "1", - "title": "work", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "2"}, - } - ], - }, - { - "id": "2", - "title": "directparentwork", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "3"}, - } - ], - }, - { - "id": "3", - "title": "parentwork", - }, - ] - - return { - "work": { - **next(w for w in works if mbid == w["id"]), - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } - } - - -@patch("musicbrainzngs.get_work_by_id", Mock(side_effect=mock_workid_response)) class ParentWorkTest(PluginTestCase): plugin = "parentwork" + @pytest.fixture(autouse=True) + def patch_works(self, requests_mock): + requests_mock.get( + "/ws/2/work/1?inc=work-rels%2Bartist-rels", + json={ + "id": "1", + "title": "work", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "2"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/2?inc=work-rels%2Bartist-rels", + json={ + "id": "2", + "title": "directparentwork", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "3"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/3?inc=work-rels%2Bartist-rels", + json={ + "id": "3", + "title": "parentwork", + "artist-relations": [ + { + "type": "composer", + "artist": { + "name": "random composer", + "sort-name": "composer, random", + }, + } + ], + }, + ) + def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib)