From 59ac06b405b6b8a51790cefc57ff297b0f8f9e53 Mon Sep 17 00:00:00 2001 From: Jonas Winkler <17569239+jonaswinkler@users.noreply.github.com> Date: Wed, 12 Jun 2024 00:33:04 +0200 Subject: [PATCH] implement mbsubmit create release on musicbrainz --- beetsplug/mbsubmit.py | 364 +++++++++++++++++++++++++++++++++- docs/changelog.rst | 5 + docs/plugins/mbsubmit.rst | 43 +++- test/plugins/test_mbsubmit.py | 5 +- 4 files changed, 412 insertions(+), 5 deletions(-) diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index d215e616c1..8346252818 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -18,31 +18,298 @@ parseable by the MusicBrainz track parser [1]. Programmatic submitting is not implemented by MusicBrainz yet. +The plugin also allows the user to open the tracks in MusicBrainz Picard [2]. + +Another option this plugin provides is to help with creating a new release +on MusicBrainz by seeding the MusicBrainz release editor [3]. This works in +the following way: + +- Host a small web server that serves a web page. When loaded by the user, + this page will automatically POST data to MusicBrainz as described in [3]. +- The same web server also listens for a callback from MusicBrainz, see + redirect_uri [3] and will try to import an album using the newly created + release. +- jwt tokens with random keys are used to prevent using this web server in + unintended ways. + +This feature is loosely based on how browser integration is implemented in +Picard [4]. + [1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings +[2] https://picard.musicbrainz.org/ +[3] https://musicbrainz.org/doc/Development/Seeding/Release_Editor +[4] https://github.com/metabrainz/picard/blob/master/picard/browser/browser.py """ - import subprocess +import threading +import time +import uuid +import webbrowser +from collections import defaultdict +from html import escape +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from secrets import token_bytes +from urllib.parse import parse_qs, urlparse -from beets import ui +from beets import autotag, ui from beets.autotag import Recommendation +from beets.importer import ImportTask from beets.plugins import BeetsPlugin +from beets.ui import print_ from beets.ui.commands import PromptChoice from beets.util import displayable_path from beetsplug.info import print_data +try: + import jwt +except ImportError: + jwt = None + +_form_template = """ + + +
+ + + +""" + +_form_input_template = '' + + +class RequestHandler(BaseHTTPRequestHandler): + """ + Request handler for built-in web server + """ + + def __init__(self, plugin: "MBSubmitPlugin", *args): + self._plugin = plugin + super(RequestHandler, self).__init__(*args) + + def do_GET(self): + try: + self._handle_get() + except Exception as e: + self._plugin._log.error("Unexpected server error", exc_info=e) + self._response(500, "Unexpected server error") + + def log_message(self, format, *args): + self._plugin._log.debug(format, *args) + + def _handle_get(self): + parsed = urlparse(self.path) + args = parse_qs(parsed.query) + action = parsed.path + + if action == "/add": + # Generates a web page using _form_template that POSTs data to the MusicBrainz release editor. + self._add(args) + elif action == "/complete_add": + # MusicBrainz redirects to this endpoint after successfully adding a release. The ID of the new release + # is provided, completing the flow. + self._complete_add(args) + else: + self._response(404, "Unknown action.") + + def _add(self, args): + if "token" in args: + token = args["token"][0] + try: + payload = jwt.decode( + token, + self._plugin.jwt_key, + algorithms=self._plugin.jwt_algorithm, + ) + except jwt.InvalidTokenError: + self._response(400, "Invalid token.") + return + + if ( + "task_key" in payload + and payload["task_key"] in self._plugin.server_tasks + ): + formdata = self._plugin.server_tasks[payload["task_key"]] + template = _form_template.format( + action=escape("https://musicbrainz.org/release/add"), + form_data="".join( + [ + _form_input_template.format( + name=escape(str(k)), value=escape(str(v)) + ) + for k, v in formdata.items() + ] + ), + ) + self._response(200, template, content_type="text/html") + else: + self._response(404, "task_key not found") + else: + self._response(400, "token missing.") + + def _complete_add(self, args): + if "release_mbid" in args and "token" in args: + token = args["token"][0] + try: + payload = jwt.decode( + token, + self._plugin.jwt_key, + algorithms=self._plugin.jwt_algorithm, + ) + except jwt.InvalidTokenError: + self._response(400, "Invalid token.") + return + + release_mbid = args["release_mbid"][0] + + if ( + "task_key" in payload + and payload["task_key"] in self._plugin.server_tasks + ): + self._plugin.server_task_results[payload["task_key"]] = ( + release_mbid + ) + self._response( + 200, + f"Release {release_mbid} added. You can close this browser window now and return to beets.", + ) + else: + self._response(404, "task_key not found") + else: + self._response(400, "release_mbid or token missing.") + + def _response( + self, code: int, content: str = "", content_type: str = "text/plain" + ): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "max-age=0") + self.end_headers() + self.wfile.write(content.encode()) + + +def join_phrase(i, total): + if i < total - 2: + return ", " + elif i < total - 1: + return " & " + else: + return "" + + +def build_formdata(task: ImportTask, redirect_uri: str): + formdata = dict() + + labels = set() + album_artists = set() + + track_counter = defaultdict(int) + + for track in task.items: + if "name" not in formdata and track.album: + formdata["name"] = track.album + if "type" not in formdata and track.albumtype: + formdata["type"] = track.albumtype + if "barcode" not in formdata and track.barcode: + formdata["barcode"] = track.barcode + if "events.0.date.year" not in formdata and track.year: + formdata["events.0.date.year"] = track.year + if "events.0.date.month" not in formdata and track.month: + formdata["events.0.date.month"] = track.month + if "events.0.date.day" not in formdata and track.day: + formdata["events.0.date.day"] = track.day + + if track.label: + labels.add(track.label) + + if track.albumartists: + for artist in track.albumartists: + album_artists.add(artist) + elif track.albumartist: + album_artists.add(track.albumartist) + + if track.disc: + medium_index = track.disc - 1 + else: + medium_index = 0 + + track_index = track_counter[medium_index] + + if f"mediums.{medium_index}.format" not in formdata and track.media: + formdata[f"mediums.{medium_index}.format"] = track.media + + formdata[f"mediums.{medium_index}.track.{track_index}.name"] = ( + track.title + ) + formdata[f"mediums.{medium_index}.track.{track_index}.number"] = ( + track.track + ) + formdata[f"mediums.{medium_index}.track.{track_index}.length"] = int( + track.length * 1000 + ) # in milliseconds + + if track.artists: + track_artists = track.artists + elif track.artist: + track_artists = [track.artist] + else: + track_artists = [] + + for i, artist in enumerate(track_artists): + formdata[ + f"mediums.{medium_index}.track.{track_index}.artist_credit.names.{i}.artist.name" + ] = artist + if join_phrase(i, len(track_artists)): + formdata[ + f"mediums.{medium_index}.track.{track_index}.artist_credit.names.{i}.join_phrase" + ] = join_phrase(i, len(track_artists)) + + track_counter[medium_index] += 1 + + for i, label in enumerate(labels): + formdata[f"labels.{i}.name"] = label + + for i, artist in enumerate(album_artists): + formdata[f"artist_credit.names.{i}.artist.name"] = artist + if join_phrase(i, len(album_artists)): + formdata[f"artist_credit.names.{i}.join_phrase"] = join_phrase( + i, len(album_artists) + ) + + if redirect_uri: + formdata["redirect_uri"] = redirect_uri + + return formdata + class MBSubmitPlugin(BeetsPlugin): def __init__(self): super().__init__() + if jwt is None: + self._log.warn( + "Cannot import PyJWT, disabling 'Create release on musicbrainz' functionality" + ) + self.config.add( { "format": "$track. $title - $artist ($length)", "threshold": "medium", "picard_path": "picard", + "server_hostname": "127.0.0.1", + "server_port": 29661, + "server_open_method": "show_link", } ) + self.server_open_method = self.config["server_open_method"].as_choice( + ["open_browser", "show_link"] + ) + self.server_hostname = self.config["server_hostname"].as_str() + self.server_port = self.config["server_port"].as_number() + # Validate and store threshold. self.threshold = self.config["threshold"].as_choice( { @@ -57,12 +324,73 @@ def __init__(self): "before_choose_candidate", self.before_choose_candidate_event ) + self._server = None + + self.jwt_key = token_bytes() + self.jwt_algorithm = "HS256" + + # When the user selects "Create release on musicbrainz", the data that is going to get POSTed to MusicBrainz is + # stored in this dict using a randomly generated key. The token in the URL opened by the user contains this + # key. The web server looks up the data in this dictionary using the key, and generates the page to be + # displayed. + self.server_tasks = dict() + self.server_task_results = dict() + + def _start_server(self): + if not self._server: + + def handler(*args): + return RequestHandler(self, *args) + + host_address = self.config["server_hostname"].get() + + for port in range( + self.server_port, min(self.server_port + 100, 65535) + ): + try: + self._server = ThreadingHTTPServer( + (host_address, port), handler + ) + except OSError: + continue + threading.Thread( + target=self._server.serve_forever, daemon=True + ).start() + self._log.debug( + f"Starting browser integration on {host_address}:{port}" + ) + break + else: + self._log.error( + "Could not find a port to start the web server on" + ) + self._stop_server() + return False + + return True + + def _stop_server(self): + if self._server: + self._log.warning("Stopping web server") + self._server.shutdown() + self._server.server_close() + self._server = None + def before_choose_candidate_event(self, session, task): if task.rec <= self.threshold: - return [ + choices = [ PromptChoice("p", "Print tracks", self.print_tracks), PromptChoice("o", "Open files with Picard", self.picard), ] + if jwt is not None and task.is_album: + choices += [ + PromptChoice( + "c", + "Create release on musicbrainz", + self.create_release_on_musicbrainz, + ), + ] + return choices def picard(self, session, task): paths = [] @@ -79,6 +407,36 @@ def print_tracks(self, session, task): for i in sorted(task.items, key=lambda i: i.track): print_data(None, i, self.config["format"].as_str()) + def create_release_on_musicbrainz(self, session, task): + if not self._start_server(): + return + task_key = str(uuid.uuid4()) + token = jwt.encode( + {"task_key": task_key}, self.jwt_key, algorithm=self.jwt_algorithm + ) + + self.server_tasks[task_key] = build_formdata( + task=task, + redirect_uri=f"http://{self.server_hostname}:{self._server.server_port}/complete_add?token={token}", + ) + + url = f"http://{self.server_hostname}:{self._server.server_port}/add?token={token}" + if self.server_open_method == "open_browser": + webbrowser.open(url) + elif self.server_open_method == "show_link": + print_(f"Open the following URL in your browser: {url}") + else: + return + + print_("Waiting for MusicBrainz release ID...") + + while (mbid := self.server_task_results.get(task_key, None)) is None: + time.sleep(0.1) + # TODO need a better way to do this + + _, _, prop = autotag.tag_album(task.items, search_ids=[mbid]) + return prop + def commands(self): """Add beet UI commands for mbsubmit.""" mbsubmit_cmd = ui.Subcommand( diff --git a/docs/changelog.rst b/docs/changelog.rst index a245cc623e..27e309e4ed 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,11 @@ Unreleased Changelog goes here! Please add your entry to the bottom of one of the lists below! +New features: + +* :doc:`plugins/mbsubmit`: Add new prompt choice "Create release on musicbrainz", automating + the process as much as possible. + Bug fixes: * Improved naming of temporary files by separating the random part with the file extension. diff --git a/docs/plugins/mbsubmit.rst b/docs/plugins/mbsubmit.rst index 0e86ddc698..9cd0433d3b 100644 --- a/docs/plugins/mbsubmit.rst +++ b/docs/plugins/mbsubmit.rst @@ -9,11 +9,24 @@ that is parseable by MusicBrainz's `track parser`_. The prompt choices are: - Print the tracks to stdout in a format suitable for MusicBrainz's `track parser`_. +- Create a new release on MusicBrainz, opens + https://musicbrainz.org/release/add in a new browser window with + fields pre-populated using existing metadata. + - Open the program `Picard`_ with the unmatched folder as an input, allowing you to start submitting the unmatched release to MusicBrainz with many input fields already filled in, thanks to Picard reading the preexisting tags of the files. +To create new releases on MusicBrainz with this plugin you need to install the +`PyJWT`_ library with: + +.. code-block:: console + + $ pip install beets[mbsubmit] + +.. _PyJWT: https://pyjwt.readthedocs.io/en/stable/ + For the last option, `Picard`_ is assumed to be installed and available on the machine including a ``picard`` executable. Picard developers list `download options`_. `other GNU/Linux distributions`_ may distribute Picard via their @@ -34,7 +47,7 @@ choice is demonstrated:: No matching release found for 3 tracks. For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, - Print tracks, Open files with Picard? p + Print tracks, Open files with Picard, Create release on musicbrainz? p 01. An Obscure Track - An Obscure Artist (3:37) 02. Another Obscure Track - An Obscure Artist (2:05) 03. The Third Track - Another Obscure Artist (3:02) @@ -56,6 +69,24 @@ the recommended workflow is to copy the output of the ``Print tracks`` choice and paste it into the parser that can be found by clicking on the "Track Parser" button on MusicBrainz "Tracklist" tab. +Create release on MusicBrainz +----------------------------- + +The https://musicbrainz.org/release/add page can be seeded with existing +metadata, as described here: https://musicbrainz.org/doc/Development/Seeding/Release_Editor. +This works in the following way: + +1. When you select the option to create a release, a local web server is started. +2. You point your web browser to that web server, either by clicking a link + displayed in the console, or by having beets open the link automatically. +3. The opened web page will redirect you to MusicBrainz, and the form fields + will be prepopulated with metadata found in the files. MusicBrainz may + ask you to confirm the action. +4. You edit the release on MusicBrainz and click "Enter edit" to finish. +5. MusicBrainz will redirect you to the local web server, submitting the ID + of the newly created release. +6. beets will add the release using the release ID returned by MusicBrainz. + Configuration ------------- @@ -70,6 +101,16 @@ file. The following options are available: Default: ``medium`` (causing the choice to be displayed for all albums that have a recommendation of medium strength or lower). Valid values: ``none``, ``low``, ``medium``, ``strong``. +- **server_hostname**: The host name of the local web server used for the + 'Create release on musicbrainz' functionality. The default is '127.0.0.1'. + Adjust this if beets is running on a different host in your local network. + Be aware that this web server is not secured in any way. +- **server_port**: The port for the local web server. Default is 29661. If + unavailable, beets will search for other ports until an available one is + found. +- **server_open_method**: Either 'open_browser' to automatically open a new + window/tab in your local browser or 'show_link' to simply show the link on + the console. - **picard_path**: The path to the ``picard`` executable. Could be an absolute path, and if not, ``$PATH`` is consulted. The default value is simply ``picard``. Windows users will have to find and specify the absolute path to diff --git a/test/plugins/test_mbsubmit.py b/test/plugins/test_mbsubmit.py index 40024bc714..77df734e82 100644 --- a/test/plugins/test_mbsubmit.py +++ b/test/plugins/test_mbsubmit.py @@ -51,7 +51,7 @@ def test_print_tracks_output(self): # Manually build the string for comparing the output. tracklist = ( - "Open files with Picard? " + "Create release on musicbrainz? " "01. Tag Title 1 - Tag Artist (0:01)\n" "02. Tag Title 2 - Tag Artist (0:01)" ) @@ -72,6 +72,9 @@ def test_print_tracks_output_as_tracks(self): ) self.assertIn(tracklist, output.getvalue()) + def test_create_release_on_musicbrainz(self): + self.fail("TODO") + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)