From c102505621f934275ff977801a26fdf0a27e34fa Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Dec 2023 10:32:55 -0500 Subject: [PATCH 01/21] Add ConnectionError handling --- beetsplug/spotify.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 24461194c..a825ef35a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -184,6 +184,9 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): except requests.exceptions.ReadTimeout: self._log.error("ReadTimeout.") raise SpotifyAPIError("Request timed out.") + except requests.exceptions.ConnectionError as e: + self._log.error(f"Network error: {e}") + raise SpotifyAPIError("Network error.") except requests.exceptions.RequestException as e: if e.response.status_code == 401: self._log.debug( From 79216e1f64c9739af73a9a8f3e1986659d6fb878 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Dec 2023 10:37:06 -0500 Subject: [PATCH 02/21] Update changelog.rst --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8266e8550..3872db865 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,6 +148,7 @@ New features: Bug fixes: +* :doc:`/plugins/spotify`: Improve handling of ConnectionError. * :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds. :bug:`4983` * :doc:`/plugins/spotify`: Add bad gateway (502) error handling. From 316b22e9f9aadf780f900047529f71fdbde2bde4 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Dec 2023 11:22:28 -0500 Subject: [PATCH 03/21] Code cleanup --- beetsplug/spotify.py | 67 +++++++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a825ef35a..03f68a68a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -188,41 +188,38 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): self._log.error(f"Network error: {e}") raise SpotifyAPIError("Network error.") except requests.exceptions.RequestException as e: - if e.response.status_code == 401: - self._log.debug( - f"{self.data_source} access token has expired. " - f"Reauthenticating." - ) - self._authenticate() - return self._handle_response(request_type, url, params=params) - elif e.response.status_code == 404: - raise SpotifyAPIError( - f"API Error: {e.response.status_code}\n" - f"URL: {url}\nparams: {params}" - ) - elif e.response.status_code == 429: - if retry_count >= max_retries: - raise SpotifyAPIError("Maximum retries reached.") - seconds = response.headers.get( - "Retry-After", DEFAULT_WAITING_TIME - ) - self._log.debug( - f"Too many API requests. Retrying after " - f"{seconds} seconds." - ) - time.sleep(int(seconds) + 1) - return self._handle_response( - request_type, - url, - params=params, - retry_count=retry_count + 1, - ) - elif e.response.status_code == 503: - self._log.error("Service Unavailable.") - raise SpotifyAPIError("Service Unavailable.") - elif e.response.status_code == 502: - self._log.error("Bad Gateway.") - raise SpotifyAPIError("Bad Gateway.") + status_code = e.response.status_code if e.response else None + error_messages = { + 401: f"{self.data_source} access token has expired. Reauthenticating.", + 404: f"API Error: {status_code}\nURL: {url}\nparams: {params}", + 429: "Too many API requests.", + 502: "Bad Gateway.", + 503: "Service Unavailable.", + } + + if status_code in error_messages: + self._log.debug(error_messages[status_code]) + if status_code == 401: + self._authenticate() + return self._handle_response( + request_type, url, params=params + ) + elif status_code == 429: + if retry_count >= max_retries: + raise SpotifyAPIError("Maximum retries reached.") + seconds = response.headers.get( + "Retry-After", DEFAULT_WAITING_TIME + ) + self._log.debug(f"Retrying after {seconds} seconds.") + time.sleep(int(seconds) + 1) + return self._handle_response( + request_type, + url, + params=params, + retry_count=retry_count + 1, + ) + else: + raise SpotifyAPIError(error_messages[status_code]) elif e.response is not None: raise SpotifyAPIError( f"{self.data_source} API error:\n{e.response.text}\n" From bdc7de874dc05a53d7d66b3a47f27adef9793ab8 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Dec 2023 11:37:01 -0500 Subject: [PATCH 04/21] Revert code cleanup --- beetsplug/spotify.py | 69 +++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 03f68a68a..ee536464f 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -188,38 +188,41 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): self._log.error(f"Network error: {e}") raise SpotifyAPIError("Network error.") except requests.exceptions.RequestException as e: - status_code = e.response.status_code if e.response else None - error_messages = { - 401: f"{self.data_source} access token has expired. Reauthenticating.", - 404: f"API Error: {status_code}\nURL: {url}\nparams: {params}", - 429: "Too many API requests.", - 502: "Bad Gateway.", - 503: "Service Unavailable.", - } - - if status_code in error_messages: - self._log.debug(error_messages[status_code]) - if status_code == 401: - self._authenticate() - return self._handle_response( - request_type, url, params=params - ) - elif status_code == 429: - if retry_count >= max_retries: - raise SpotifyAPIError("Maximum retries reached.") - seconds = response.headers.get( - "Retry-After", DEFAULT_WAITING_TIME - ) - self._log.debug(f"Retrying after {seconds} seconds.") - time.sleep(int(seconds) + 1) - return self._handle_response( - request_type, - url, - params=params, - retry_count=retry_count + 1, - ) - else: - raise SpotifyAPIError(error_messages[status_code]) + if e.response.status_code == 401: + self._log.debug( + f"{self.data_source} access token has expired. " + f"Reauthenticating." + ) + self._authenticate() + return self._handle_response(request_type, url, params=params) + elif e.response.status_code == 404: + raise SpotifyAPIError( + f"API Error: {e.response.status_code}\n" + f"URL: {url}\nparams: {params}" + ) + elif e.response.status_code == 429: + if retry_count >= max_retries: + raise SpotifyAPIError("Maximum retries reached.") + seconds = response.headers.get( + "Retry-After", DEFAULT_WAITING_TIME + ) + self._log.debug( + f"Too many API requests. Retrying after " + f"{seconds} seconds." + ) + time.sleep(int(seconds) + 1) + return self._handle_response( + request_type, + url, + params=params, + retry_count=retry_count + 1, + ) + elif e.response.status_code == 503: + self._log.error("Service Unavailable.") + raise SpotifyAPIError("Service Unavailable.") + elif e.response.status_code == 502: + self._log.error("Bad Gateway.") + raise SpotifyAPIError("Bad Gateway.") elif e.response is not None: raise SpotifyAPIError( f"{self.data_source} API error:\n{e.response.text}\n" @@ -705,4 +708,4 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): ) except SpotifyAPIError as e: self._log.debug("Spotify API error: {}", e) - return None + return None \ No newline at end of file From 4348a49a4f9a4f25dca20dac0e7d54dd6fc91d46 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Dec 2023 11:38:34 -0500 Subject: [PATCH 05/21] Formatting fixes --- beetsplug/spotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ee536464f..a825ef35a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -708,4 +708,4 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): ) except SpotifyAPIError as e: self._log.debug("Spotify API error: {}", e) - return None \ No newline at end of file + return None From 8a3b9acdee1b7ea8c2f9de98c7fe6d42f5d86d9e Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Mon, 4 Dec 2023 22:53:32 +0100 Subject: [PATCH 06/21] expose import.quiet_fallback as cli option --- beets/ui/commands.py | 6 ++++++ docs/changelog.rst | 1 + docs/reference/cli.rst | 6 ++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 439858477..63f25fca9 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1460,6 +1460,12 @@ import_cmd.parser.add_option( dest="quiet", help="never prompt for input: skip albums instead", ) +import_cmd.parser.add_option( + "--quiet-fallback", + type="string", + dest="quiet_fallback", + help="decision in quiet mode when no strong match: skip or asis", +) import_cmd.parser.add_option( "-l", "--log", diff --git a/docs/changelog.rst b/docs/changelog.rst index 3872db865..c69ec82f8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -145,6 +145,7 @@ New features: plugin which allows to replace fields based on a given library query. * :doc:`/plugins/lyrics`: Add LRCLIB as a new lyrics provider and a new `synced` option to prefer synced lyrics over plain lyrics. +* :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. Bug fixes: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 86c615dbb..a2997c70e 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -93,8 +93,10 @@ Optional command flags: * Relatedly, the ``-q`` (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode - pessimistically skips the album. The quiet mode also disables the tagger's - ability to resume interrupted imports. + performs a fallback action that can be configured using the + ``quiet_fallback`` configuration or ``--quiet-fallback`` CLI option. + By default it pessimistically ``skip``s the file. + Alternatively, it can be used as is, by configuring ``asis``. * Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by From 177f284d401979b3024bbbb87e585297188c8e1b Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Mon, 4 Dec 2023 02:21:10 +0100 Subject: [PATCH 07/21] expose incremental_skip_later as cli option Closes #4958 --- beets/ui/commands.py | 14 ++++++++++++++ docs/changelog.rst | 1 + docs/reference/cli.rst | 9 +++++++++ 3 files changed, 24 insertions(+) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 63f25fca9..26eb5320a 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1506,6 +1506,20 @@ import_cmd.parser.add_option( action="store_false", help="do not skip already-imported directories", ) +import_cmd.parser.add_option( + "-R", + "--incremental-skip-later", + action="store_true", + dest="incremental_skip_later", + help="do not record skipped files during incremental import", +) +import_cmd.parser.add_option( + "-r", + "--noincremental-skip-later", + action="store_false", + dest="incremental_skip_later", + help="record skipped files during incremental import", +) import_cmd.parser.add_option( "--from-scratch", dest="from_scratch", diff --git a/docs/changelog.rst b/docs/changelog.rst index c69ec82f8..19a1fb43f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -146,6 +146,7 @@ New features: * :doc:`/plugins/lyrics`: Add LRCLIB as a new lyrics provider and a new `synced` option to prefer synced lyrics over plain lyrics. * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. +* :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. Bug fixes: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index a2997c70e..8caf70763 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -115,6 +115,15 @@ Optional command flags: time, when no subdirectories will be skipped. So consider enabling the ``incremental`` configuration option. +* If you don't want to record skipped files during an *incremental* import, use + the ``--incremental-skip-later`` flag which corresponds to the + ``incremental_skip_later`` configuration option. + Setting the flag prevents beets from persisting skip decisions during a + non-interactive import so that a user can make a decision regarding + previously skipped files during a subsequent interactive import run. + To record skipped files during incremental import explicitly, use the + ``--noincremental-skip-later`` option. + * When beets applies metadata to your music, it will retain the value of any existing tags that weren't overwritten, and import them into the database. You may prefer to only use existing metadata for finding matches, and to erase it From 729a11e211078c4d9df4b660ae5b4e6e27d6e4fa Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Tue, 30 May 2023 11:25:41 +0300 Subject: [PATCH 08/21] mbsubmit: Add picard `PromptChoice` Make it possible to open picard from the import menu when there are weak recommendations. --- beetsplug/mbsubmit.py | 19 ++++++++++++++++++- test/plugins/test_mbsubmit.py | 6 ++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index e4c0f372e..d215e616c 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -21,11 +21,13 @@ implemented by MusicBrainz yet. [1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings """ +import subprocess from beets import ui from beets.autotag import Recommendation from beets.plugins import BeetsPlugin from beets.ui.commands import PromptChoice +from beets.util import displayable_path from beetsplug.info import print_data @@ -37,6 +39,7 @@ class MBSubmitPlugin(BeetsPlugin): { "format": "$track. $title - $artist ($length)", "threshold": "medium", + "picard_path": "picard", } ) @@ -56,7 +59,21 @@ class MBSubmitPlugin(BeetsPlugin): def before_choose_candidate_event(self, session, task): if task.rec <= self.threshold: - return [PromptChoice("p", "Print tracks", self.print_tracks)] + return [ + PromptChoice("p", "Print tracks", self.print_tracks), + PromptChoice("o", "Open files with Picard", self.picard), + ] + + def picard(self, session, task): + paths = [] + for p in task.paths: + paths.append(displayable_path(p)) + try: + picard_path = self.config["picard_path"].as_str() + subprocess.Popen([picard_path] + paths) + self._log.info("launched picard from\n{}", picard_path) + except OSError as exc: + self._log.error(f"Could not open picard, got error:\n{exc}") def print_tracks(self, session, task): for i in sorted(task.items, key=lambda i: i.track): diff --git a/test/plugins/test_mbsubmit.py b/test/plugins/test_mbsubmit.py index 6f9c81c04..e495a73a9 100644 --- a/test/plugins/test_mbsubmit.py +++ b/test/plugins/test_mbsubmit.py @@ -45,7 +45,7 @@ class MBSubmitPluginTest( # Manually build the string for comparing the output. tracklist = ( - "Print tracks? " + "Open files with Picard? " "01. Tag Title 1 - Tag Artist (0:01)\n" "02. Tag Title 2 - Tag Artist (0:01)" ) @@ -61,7 +61,9 @@ class MBSubmitPluginTest( self.importer.run() # Manually build the string for comparing the output. - tracklist = "Print tracks? " "02. Tag Title 2 - Tag Artist (0:01)" + tracklist = ( + "Open files with Picard? " "02. Tag Title 2 - Tag Artist (0:01)" + ) self.assertIn(tracklist, output.getvalue()) From 9357448bdeadb5f8a8a2b7a8fc55e432dd4aa906 Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Fri, 27 Oct 2023 17:48:16 +0300 Subject: [PATCH 09/21] mbsubmit: document new prompt choices --- docs/changelog.rst | 1 + docs/plugins/mbsubmit.rst | 34 ++++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1ff5b59c8..ce5164ef6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,7 @@ Major new features: New features: +* :doc:`plugins/mbsubmit`: add new prompt choices helping further to submit unmatched tracks to MusicBrainz faster. * :doc:`plugins/spotify`: We now fetch track's ISRC, EAN, and UPC identifiers from Spotify when using the ``spotifysync`` command. :bug:`4992` * :doc:`plugins/discogs`: supply a value for the `cover_art_url` attribute, for use by `fetchart`. diff --git a/docs/plugins/mbsubmit.rst b/docs/plugins/mbsubmit.rst index 5cb9be8f1..0e86ddc69 100644 --- a/docs/plugins/mbsubmit.rst +++ b/docs/plugins/mbsubmit.rst @@ -1,23 +1,40 @@ MusicBrainz Submit Plugin ========================= -The ``mbsubmit`` plugin provides an extra prompt choice during an import -session and a ``mbsubmit`` command that prints the tracks of the current -album in a format that is parseable by MusicBrainz's `track parser`_. +The ``mbsubmit`` plugin provides extra prompt choices when an import session +fails to find a good enough match for a release. Additionally, it provides an +``mbsubmit`` command that prints the tracks of the current album in a format +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`_. + +- 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. + +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 +package manager as well. .. _track parser: https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings +.. _Picard: https://picard.musicbrainz.org/ +.. _download options: https://picard.musicbrainz.org/downloads/ +.. _other GNU/Linux distributions: https://repology.org/project/picard-tagger/versions Usage ----- Enable the ``mbsubmit`` plugin in your configuration (see :ref:`using-plugins`) -and select the ``Print tracks`` choice which is by default displayed when no -strong recommendations are found for the album:: +and select one of the options mentioned above. Here the option ``Print tracks`` +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? p + Print tracks, Open files with Picard? 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) @@ -53,6 +70,11 @@ 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``. +- **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 + their ``picard.exe``. That would probably be: + ``C:\Program Files\MusicBrainz Picard\picard.exe``. Please note that some values of the ``threshold`` configuration option might require other ``beets`` command line switches to be enabled in order to work as From 2136f3a3cd71e583602a0f8998f58dac96af8a74 Mon Sep 17 00:00:00 2001 From: David Logie Date: Thu, 7 Dec 2023 09:28:29 +0000 Subject: [PATCH 10/21] Bring back NO_COLOR support. Fixes #5028. --- beets/ui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index ef96c9c38..a98e8ee43 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -591,7 +591,7 @@ def colorize(color_name, text): """Colorize text if colored output is enabled. (Like _colorize but conditional.) """ - if config["ui"]["color"]: + if config["ui"]["color"] and "NO_COLOR" not in os.environ: global COLORS if not COLORS: # Read all color configurations and set global variable COLORS. From c1a232ec7be13f710fa764d39e13483b093fad9a Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 11 Dec 2023 11:01:54 +0100 Subject: [PATCH 11/21] Fix some changelog entries --- docs/changelog.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 74b925e21..5b074f592 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -268,8 +268,10 @@ Bug fixes: :bug:`4822` * Fix bug where an interrupted import process poisons the database, causing a null path that can't be removed. -* Fix bug where empty artist and title fields would return None instead of an - empty list in the discord plugin. :bug:`4973` + :bug:`4906` +* :doc:`/plugins/discogs`: Fix bug where empty artist and title fields would + return None instead of an empty list. + :bug:`4973` * Fix bug regarding displaying tracks that have been changed not being displayed unless the detail configuration is enabled. From fd795c9da606196948effd1e1f29db2bde81015b Mon Sep 17 00:00:00 2001 From: David Logie Date: Tue, 12 Dec 2023 09:03:17 +0000 Subject: [PATCH 12/21] Add changelog entry for #5028. --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 52e7069bf..ae284b1cd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,7 +13,7 @@ Major new features: * The beets importer UI received a major overhaul. Several new configuration options are available for customizing layout and colors: :ref:`ui_options`. - :bug:`3721` + :bug:`3721` :bug:`5028` New features: From 4b1c7dd8be8cf2454739af9b4999f9bc3b50df80 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 13 Dec 2023 11:25:27 +0100 Subject: [PATCH 13/21] Specify new advancedrewrite configuration in docs --- docs/plugins/advancedrewrite.rst | 45 +++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/plugins/advancedrewrite.rst b/docs/plugins/advancedrewrite.rst index 8ac0e277e..27d434cac 100644 --- a/docs/plugins/advancedrewrite.rst +++ b/docs/plugins/advancedrewrite.rst @@ -3,28 +3,53 @@ Advanced Rewrite Plugin The ``advancedrewrite`` plugin lets you easily substitute values in your templates and path formats, similarly to the :doc:`/plugins/rewrite`. -Please make sure to read the documentation of that plugin first. +It's recommended to read the documentation of that plugin first. -The *advanced* rewrite plugin doesn't match the rewritten field itself, +The *advanced* rewrite plugin does not only support the simple rule format +of the ``rewrite`` plugin, but also an advanced format: +there, the plugin doesn't consider the value of the rewritten field, but instead checks if the given item matches a :doc:`query `. Only then, the field is replaced with the given value. +It's also possible to replace multiple fields at once, +and even supports multi-valued fields. To use advanced field rewriting, first enable the ``advancedrewrite`` plugin (see :ref:`using-plugins`). Then, make a ``advancedrewrite:`` section in your config file to contain your rewrite rules. -In contrast to the normal ``rewrite`` plugin, you need to provide a list -of replacement rule objects, each consisting of a query, a field name, -and the replacement value. +In contrast to the normal ``rewrite`` plugin, you need to provide a list of +replacement rule objects, which can have a different syntax depending on +the rule complexity. +The simple syntax is the same as the one of the rewrite plugin and allows +to replace a single field:: + + advancedrewrite: + - artist ODD EYE CIRCLE: 이달의 소녀 오드아이써클 + +The advanced syntax consists of a query to match against, as well as a map +of replacements to apply. For example, to credit all songs of ODD EYE CIRCLE before 2023 to their original group name, you can use the following rule:: advancedrewrite: - match: "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022" - field: artist - replacement: "이달의 소녀 오드아이써클" + replacements: + artist: 이달의 소녀 오드아이써클 + artist_sort: LOONA / ODD EYE CIRCLE + +Note how the sort name is also rewritten within the same rule. +You can specify as many fields as you'd like in the replacements map. + +If you need to work with multi-valued fields, you can use the following syntax:: + + advancedrewrite: + - match: "artist:배유빈 feat. 김미현" + replacements: + artists: + - 유빈 + - 미미 As a convenience, the plugin applies patterns for the ``artist`` field to the ``albumartist`` field as well. (Otherwise, you would probably want to duplicate @@ -35,5 +60,7 @@ formats; it initially does not modify files' metadata tags or the values tracked by beets' library database, but since it *rewrites all field lookups*, it modifies the file's metadata anyway. See comments in issue :bug:`2786`. -As an alternative to this plugin the simpler :doc:`/plugins/rewrite` or -similar :doc:`/plugins/substitute` can be used. +As an alternative to this plugin the simpler but less powerful +:doc:`/plugins/rewrite` can be used. +If you don't want to modify the item's metadata and only replace values +in file paths, you can check out the :doc:`/plugins/substitute`. From 304a052dfdd9019abf26e6f0842da99a50167f85 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 13 Dec 2023 14:48:43 +0100 Subject: [PATCH 14/21] advancedrewrite: Support simple syntax and improve advanced syntax --- beetsplug/advancedrewrite.py | 155 ++++++++++++++++++++++----- test/plugins/test_advancedrewrite.py | 142 ++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 29 deletions(-) create mode 100644 test/plugins/test_advancedrewrite.py diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py index fbb455314..6b7fad1a2 100644 --- a/beetsplug/advancedrewrite.py +++ b/beetsplug/advancedrewrite.py @@ -14,18 +14,40 @@ """Plugin to rewrite fields based on a given query.""" +import re import shlex from collections import defaultdict import confuse -from beets import ui from beets.dbcore import AndQuery, query_from_strings +from beets.dbcore.types import MULTI_VALUE_DSV from beets.library import Album, Item from beets.plugins import BeetsPlugin +from beets.ui import UserError -def rewriter(field, rules): +def simple_rewriter(field, rules): + """Template field function factory. + + Create a template field function that rewrites the given field + with the given rewriting rules. + ``rules`` must be a list of (pattern, replacement) pairs. + """ + + def fieldfunc(item): + value = item._values_fixed[field] + for pattern, replacement in rules: + if pattern.match(value.lower()): + # Rewrite activated. + return replacement + # Not activated; return original value. + return value + + return fieldfunc + + +def advanced_rewriter(field, rules): """Template field function factory. Create a template field function that rewrites the given field @@ -53,40 +75,115 @@ class AdvancedRewritePlugin(BeetsPlugin): super().__init__() template = confuse.Sequence( - { - "match": str, - "field": str, - "replacement": str, - } + confuse.OneOf( + [ + confuse.MappingValues(str), + { + "match": str, + "replacements": confuse.MappingValues( + confuse.OneOf([str, confuse.Sequence(str)]), + ), + }, + ] + ) ) # Gather all the rewrite rules for each field. - rules = defaultdict(list) + simple_rules = defaultdict(list) + advanced_rules = defaultdict(list) for rule in self.config.get(template): - query = query_from_strings( - AndQuery, - Item, - prefixes={}, - query_parts=shlex.split(rule["match"]), - ) - fieldname = rule["field"] - replacement = rule["replacement"] - if fieldname not in Item._fields: - raise ui.UserError( - "invalid field name (%s) in rewriter" % fieldname + if "match" not in rule: + # Simple syntax + if len(rule) != 1: + raise UserError( + "Simple rewrites must have only one rule, " + "but found multiple entries. " + "Did you forget to prepend a dash (-)?" + ) + key, value = next(iter(rule.items())) + try: + fieldname, pattern = key.split(None, 1) + except ValueError: + raise UserError( + f"Invalid simple rewrite specification {key}" + ) + if fieldname not in Item._fields: + raise UserError( + f"invalid field name {fieldname} in rewriter" + ) + self._log.debug( + f"adding simple rewrite '{pattern}' → '{value}' " + f"for field {fieldname}" ) - self._log.debug( - "adding template field {0} → {1}", fieldname, replacement - ) - rules[fieldname].append((query, replacement)) - if fieldname == "artist": - # Special case for the artist field: apply the same - # rewrite for "albumartist" as well. - rules["albumartist"].append((query, replacement)) + pattern = re.compile(pattern.lower()) + simple_rules[fieldname].append((pattern, value)) + if fieldname == "artist": + # Special case for the artist field: apply the same + # rewrite for "albumartist" as well. + simple_rules["albumartist"].append((pattern, value)) + else: + # Advanced syntax + match = rule["match"] + replacements = rule["replacements"] + if len(replacements) == 0: + raise UserError( + "Advanced rewrites must have at least one replacement" + ) + query = query_from_strings( + AndQuery, + Item, + prefixes={}, + query_parts=shlex.split(match), + ) + for fieldname, replacement in replacements.items(): + if fieldname not in Item._fields: + raise UserError( + f"Invalid field name {fieldname} in rewriter" + ) + self._log.debug( + f"adding advanced rewrite to '{replacement}' " + f"for field {fieldname}" + ) + if isinstance(replacement, list): + if Item._fields[fieldname] is not MULTI_VALUE_DSV: + raise UserError( + f"Field {fieldname} is not a multi-valued field " + f"but a list was given: {', '.join(replacement)}" + ) + elif isinstance(replacement, str): + if Item._fields[fieldname] is MULTI_VALUE_DSV: + replacement = list(replacement) + else: + raise UserError( + f"Invalid type of replacement {replacement} " + f"for field {fieldname}" + ) + + advanced_rules[fieldname].append((query, replacement)) + # Special case for the artist(s) field: + # apply the same rewrite for "albumartist(s)" as well. + if fieldname == "artist": + advanced_rules["albumartist"].append( + (query, replacement) + ) + elif fieldname == "artists": + advanced_rules["albumartists"].append( + (query, replacement) + ) + elif fieldname == "artist_sort": + advanced_rules["albumartist_sort"].append( + (query, replacement) + ) # Replace each template field with the new rewriter function. - for fieldname, fieldrules in rules.items(): - getter = rewriter(fieldname, fieldrules) + for fieldname, fieldrules in simple_rules.items(): + getter = simple_rewriter(fieldname, fieldrules) + self.template_fields[fieldname] = getter + if fieldname in Album._fields: + self.album_template_fields[fieldname] = getter + + for fieldname, fieldrules in advanced_rules.items(): + getter = advanced_rewriter(fieldname, fieldrules) self.template_fields[fieldname] = getter if fieldname in Album._fields: self.album_template_fields[fieldname] = getter diff --git a/test/plugins/test_advancedrewrite.py b/test/plugins/test_advancedrewrite.py new file mode 100644 index 000000000..74d2e5db0 --- /dev/null +++ b/test/plugins/test_advancedrewrite.py @@ -0,0 +1,142 @@ +# This file is part of beets. +# Copyright 2023, Max Rumpf. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Test the advancedrewrite plugin for various configurations. +""" + +import unittest +from test.helper import TestHelper + +from beets.ui import UserError + +PLUGIN_NAME = "advancedrewrite" + + +class AdvancedRewritePluginTest(unittest.TestCase, TestHelper): + def setUp(self): + self.setup_beets() + + def tearDown(self): + self.unload_plugins() + self.teardown_beets() + + def test_simple_rewrite_example(self): + self.config[PLUGIN_NAME] = [ + {"artist ODD EYE CIRCLE": "이달의 소녀 오드아이써클"}, + ] + self.load_plugins(PLUGIN_NAME) + + item = self.add_item( + title="Uncover", + artist="ODD EYE CIRCLE", + albumartist="ODD EYE CIRCLE", + album="Mix & Match", + ) + + self.assertEqual(item.artist, "이달의 소녀 오드아이써클") + + def test_advanced_rewrite_example(self): + self.config[PLUGIN_NAME] = [ + { + "match": "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022", + "replacements": { + "artist": "이달의 소녀 오드아이써클", + "artist_sort": "LOONA / ODD EYE CIRCLE", + }, + }, + ] + self.load_plugins(PLUGIN_NAME) + + item_a = self.add_item( + title="Uncover", + artist="ODD EYE CIRCLE", + albumartist="ODD EYE CIRCLE", + artist_sort="ODD EYE CIRCLE", + albumartist_sort="ODD EYE CIRCLE", + album="Mix & Match", + mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c", + year=2017, + ) + item_b = self.add_item( + title="Air Force One", + artist="ODD EYE CIRCLE", + albumartist="ODD EYE CIRCLE", + artist_sort="ODD EYE CIRCLE", + albumartist_sort="ODD EYE CIRCLE", + album="ODD EYE CIRCLE ", + mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c", + year=2023, + ) + + # Assert that all replacements were applied to item_a + self.assertEqual("이달의 소녀 오드아이써클", item_a.artist) + self.assertEqual("LOONA / ODD EYE CIRCLE", item_a.artist_sort) + self.assertEqual("LOONA / ODD EYE CIRCLE", item_a.albumartist_sort) + + # Assert that no replacements were applied to item_b + self.assertEqual("ODD EYE CIRCLE", item_b.artist) + + def test_advanced_rewrite_example_with_multi_valued_field(self): + self.config[PLUGIN_NAME] = [ + { + "match": "artist:배유빈 feat. 김미현", + "replacements": { + "artists": ["유빈", "미미"], + }, + }, + ] + self.load_plugins(PLUGIN_NAME) + + item = self.add_item( + artist="배유빈 feat. 김미현", + artists=["배유빈", "김미현"], + ) + + self.assertEqual(item.artists, ["유빈", "미미"]) + + def test_fail_when_replacements_empty(self): + self.config[PLUGIN_NAME] = [ + { + "match": "artist:A", + "replacements": {}, + }, + ] + with self.assertRaises( + UserError, + msg="Advanced rewrites must have at least one replacement", + ): + self.load_plugins(PLUGIN_NAME) + + def test_fail_when_rewriting_single_valued_field_with_list(self): + self.config[PLUGIN_NAME] = [ + { + "match": "artist:'A & B'", + "replacements": { + "artist": ["C", "D"], + }, + }, + ] + with self.assertRaises( + UserError, + msg="Field artist is not a multi-valued field but a list was given: C, D", + ): + self.load_plugins(PLUGIN_NAME) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == "__main__": + unittest.main(defaultTest="suite") From b07a2e42f4efa6c43c86b1ff65ad0f514ee5235b Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Wed, 13 Dec 2023 20:57:07 +0100 Subject: [PATCH 15/21] smartplaylist: add extm3u/extinf/m3u8 support This is to be able to display meaningful metadata and search a playlist within a player without having to load the linked audio files of a playlist. --- beetsplug/smartplaylist.py | 32 +++++++++++++++--- docs/changelog.rst | 1 + docs/plugins/smartplaylist.rst | 1 + test/plugins/test_smartplaylist.py | 52 +++++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 6e20cc21b..c892a6040 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -49,6 +49,7 @@ class SmartPlaylistPlugin(BeetsPlugin): "prefix": "", "urlencode": False, "pretend_paths": False, + "extm3u": False, } ) @@ -71,6 +72,17 @@ class SmartPlaylistPlugin(BeetsPlugin): action="store_true", help="display query results but don't write playlist files.", ) + spl_update.parser.add_option( + "--extm3u", + action="store_true", + help="add artist/title as m3u8 comments to playlists.", + ) + spl_update.parser.add_option( + "--no-extm3u", + action="store_false", + dest="extm3u", + help="do not add artist/title as extm3u comments to playlists.", + ) spl_update.func = self.update_cmd return [spl_update] @@ -99,7 +111,7 @@ class SmartPlaylistPlugin(BeetsPlugin): else: self._matched_playlists = self._unmatched_playlists - self.update_playlists(lib, opts.pretend) + self.update_playlists(lib, opts.extm3u, opts.pretend) def build_queries(self): """ @@ -185,7 +197,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib, pretend=False): + def update_playlists(self, lib, extm3u=None, pretend=False): if pretend: self._log.info( "Showing query results for {0} smart playlists...", @@ -230,7 +242,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if relative_to: item_path = os.path.relpath(item.path, relative_to) if item_path not in m3us[m3u_name]: - m3us[m3u_name].append(item_path) + m3us[m3u_name].append({"item": item, "path": item_path}) if pretend and self.config["pretend_paths"]: print(displayable_path(item_path)) elif pretend: @@ -244,13 +256,23 @@ class SmartPlaylistPlugin(BeetsPlugin): os.path.join(playlist_dir, bytestring_path(m3u)) ) mkdirall(m3u_path) + extm3u = extm3u is None and self.config["extm3u"] or extm3u with open(syspath(m3u_path), "wb") as f: - for path in m3us[m3u]: + if extm3u: + f.write(b"#EXTM3U\n") + for entry in m3us[m3u]: + path = entry["path"] + item = entry["item"] if self.config["forward_slash"].get(): path = path_as_posix(path) if self.config["urlencode"]: path = bytestring_path(pathname2url(path)) - f.write(prefix + path + b"\n") + comment = "" + if extm3u: + comment = "#EXTINF:{},{} - {}\n".format( + int(item.length), item.artist, item.title + ) + f.write(comment.encode("utf-8") + prefix + path + b"\n") # Send an event when playlists were updated. send_event("smartplaylist_update") diff --git a/docs/changelog.rst b/docs/changelog.rst index 5b074f592..9259b5933 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,6 +148,7 @@ New features: `synced` option to prefer synced lyrics over plain lyrics. * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. * :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. +* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.extm3u`. Bug fixes: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index e687a68a4..6a78124e1 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -118,3 +118,4 @@ other configuration options are: - **urlencoded**: URL-encode all paths. Default: ``no``. - **pretend_paths**: When running with ``--pretend``, show the actual file paths that will be written to the m3u file. Default: ``false``. +- **extm3u**: Generate extm3u/m3u8 playlists. Default ``ǹo``. diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index a3a03b54c..f36601267 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -19,7 +19,7 @@ from shutil import rmtree from tempfile import mkdtemp from test import _common from test.helper import TestHelper -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock, Mock, PropertyMock from beets import config from beets.dbcore import OrQuery @@ -191,6 +191,56 @@ class SmartPlaylistTest(_common.TestCase): self.assertEqual(content, b"/tagada.mp3\n") + def test_playlist_update_extm3u(self): + spl = SmartPlaylistPlugin() + + i = MagicMock() + type(i).artist = PropertyMock(return_value="fake artist") + type(i).title = PropertyMock(return_value="fake title") + type(i).length = PropertyMock(return_value=300.123) + type(i).path = PropertyMock(return_value=b"/tagada.mp3") + i.evaluate_template.side_effect = lambda pl, _: pl.replace( + b"$title", + b"ta:ga:da", + ).decode() + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.items.return_value = [i] + lib.albums.return_value = [] + + q = Mock() + a_q = Mock() + pl = b"$title-my.m3u", (q, None), (a_q, None) + spl._matched_playlists = [pl] + + dir = bytestring_path(mkdtemp()) + config["smartplaylist"]["extm3u"] = True + config["smartplaylist"]["prefix"] = "http://beets:8337/files" + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = py3_path(dir) + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + lib.items.assert_called_once_with(q, None) + lib.albums.assert_called_once_with(a_q, None) + + m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u") + self.assertExists(m3u_filepath) + with open(syspath(m3u_filepath), "rb") as f: + content = f.read() + rmtree(syspath(dir)) + + self.assertEqual( + content, + b"#EXTM3U\n" + + b"#EXTINF:300,fake artist - fake title\n" + + b"http://beets:8337/files/tagada.mp3\n", + ) + class SmartPlaylistCLITest(_common.TestCase, TestHelper): def setUp(self): From 222b3a34f98b6de2c04e660cb8761ac910210c5e Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Thu, 14 Dec 2023 02:36:29 +0100 Subject: [PATCH 16/21] smartplaylist: expose config as CLI options Add CLI options to `splupdate` command: * `--playlist-dir`, `-d` * `--relative-to` * `--prefix` * `--urlencode` * `--forward-slash` * `--pretend-paths` --- beetsplug/smartplaylist.py | 53 ++++++++++++++++++++++++++++++---- docs/changelog.rst | 1 + docs/plugins/smartplaylist.rst | 7 ++++- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index c892a6040..ab561e094 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -72,16 +72,53 @@ class SmartPlaylistPlugin(BeetsPlugin): action="store_true", help="display query results but don't write playlist files.", ) + spl_update.parser.add_option( + "--pretend-paths", + action="store_true", + dest="pretend_paths", + help="in pretend mode, log the playlist item URIs/paths.", + ) + spl_update.parser.add_option( + "-d", + "--playlist-dir", + dest="playlist_dir", + metavar="PATH", + type="string", + help="directory to write the generated playlist files to.", + ) + spl_update.parser.add_option( + "--relative-to", + dest="relative_to", + metavar="PATH", + type="string", + help="Generate playlist item paths relative to this path.", + ) + spl_update.parser.add_option( + "--prefix", + type="string", + help="prepend string to every path in the playlist file.", + ) + spl_update.parser.add_option( + "--forward-slash", + action="store_true", + dest="forward_slash", + help="Force forward slash in paths within playlists.", + ) + spl_update.parser.add_option( + "--urlencode", + action="store_true", + help="URL-encode all paths.", + ) spl_update.parser.add_option( "--extm3u", action="store_true", - help="add artist/title as m3u8 comments to playlists.", + help="generate extm3u/m3u8 playlists.", ) spl_update.parser.add_option( "--no-extm3u", action="store_false", dest="extm3u", - help="do not add artist/title as extm3u comments to playlists.", + help="generate extm3u/m3u8 playlists.", ) spl_update.func = self.update_cmd return [spl_update] @@ -111,7 +148,13 @@ class SmartPlaylistPlugin(BeetsPlugin): else: self._matched_playlists = self._unmatched_playlists - self.update_playlists(lib, opts.extm3u, opts.pretend) + self.__apply_opts_to_config(opts) + self.update_playlists(lib, opts.pretend) + + def __apply_opts_to_config(self, opts): + for k, v in opts.__dict__.items(): + if v is not None and k in self.config: + self.config[k] = v def build_queries(self): """ @@ -197,7 +240,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib, extm3u=None, pretend=False): + def update_playlists(self, lib, pretend=False): if pretend: self._log.info( "Showing query results for {0} smart playlists...", @@ -256,7 +299,7 @@ class SmartPlaylistPlugin(BeetsPlugin): os.path.join(playlist_dir, bytestring_path(m3u)) ) mkdirall(m3u_path) - extm3u = extm3u is None and self.config["extm3u"] or extm3u + extm3u = self.config["extm3u"] with open(syspath(m3u_path), "wb") as f: if extm3u: f.write(b"#EXTM3U\n") diff --git a/docs/changelog.rst b/docs/changelog.rst index 9259b5933..66782408a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -149,6 +149,7 @@ New features: * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. * :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. * :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.extm3u`. +* :doc:`/plugins/smartplaylist`: Expose config options as CLI options. Bug fixes: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 6a78124e1..1d4de4eb5 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -115,7 +115,12 @@ other configuration options are: - **prefix**: Prepend this string to every path in the playlist file. For example, you could use the URL for a server where the music is stored. Default: empty string. -- **urlencoded**: URL-encode all paths. Default: ``no``. +- **urlencode**: URL-encode all paths. Default: ``no``. - **pretend_paths**: When running with ``--pretend``, show the actual file paths that will be written to the m3u file. Default: ``false``. - **extm3u**: Generate extm3u/m3u8 playlists. Default ``ǹo``. + +For many configuration options, there is a corresponding CLI option, e.g. +``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``, +``--urlencode``, ``--extm3u``, ``--pretend-paths``. +CLI options take precedence over those specified within the configuration file. From 6e5bcbc07081119f5a356f6804a9a1286be0f67e Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Fri, 15 Dec 2023 17:14:00 +0100 Subject: [PATCH 17/21] advancedrewrite: Add note about quoting issues to docs --- docs/plugins/advancedrewrite.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/plugins/advancedrewrite.rst b/docs/plugins/advancedrewrite.rst index 27d434cac..e244be44b 100644 --- a/docs/plugins/advancedrewrite.rst +++ b/docs/plugins/advancedrewrite.rst @@ -55,6 +55,31 @@ As a convenience, the plugin applies patterns for the ``artist`` field to the ``albumartist`` field as well. (Otherwise, you would probably want to duplicate every rule for ``artist`` and ``albumartist``.) +Make sure to properly quote your query strings if they contain spaces, +otherwise they might not do what you expect, or even cause beets to crash. + +Take the following example:: + + advancedrewrite: + # BAD, DON'T DO THIS! + - match: album:THE ALBUM + replacements: + artist: New artist + +On the first sight, this might look sane, and replace the artist of the album +*THE ALBUM* with *New artist*. However, due to the space and missing quotes, +this query will evaluate to ``album:THE`` and match ``ALBUM`` on any field, +including ``artist``. As ``artist`` is the field being replaced, +this query will result in infinite recursion and ultimately crash beets. + +Instead, you should use the following rule:: + + advancedrewrite: + # Note the quotes around the query string! + - match: album:"THE ALBUM" + replacements: + artist: New artist + A word of warning: This plugin theoretically only applies to templates and path formats; it initially does not modify files' metadata tags or the values tracked by beets' library database, but since it *rewrites all field lookups*, From 41719d7b499cb612f1b008cdefb07039dd0c20ad Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Fri, 15 Dec 2023 17:23:40 +0100 Subject: [PATCH 18/21] advancedrewrite: Apply same rewrite to more corresponding album fields --- beetsplug/advancedrewrite.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py index 6b7fad1a2..639aa5247 100644 --- a/beetsplug/advancedrewrite.py +++ b/beetsplug/advancedrewrite.py @@ -88,6 +88,14 @@ class AdvancedRewritePlugin(BeetsPlugin): ) ) + # Used to apply the same rewrite to the corresponding album field. + corresponding_album_fields = { + "artist": "albumartist", + "artists": "albumartists", + "artist_sort": "albumartist_sort", + "artists_sort": "albumartists_sort", + } + # Gather all the rewrite rules for each field. simple_rules = defaultdict(list) advanced_rules = defaultdict(list) @@ -117,10 +125,11 @@ class AdvancedRewritePlugin(BeetsPlugin): ) pattern = re.compile(pattern.lower()) simple_rules[fieldname].append((pattern, value)) - if fieldname == "artist": - # Special case for the artist field: apply the same - # rewrite for "albumartist" as well. - simple_rules["albumartist"].append((pattern, value)) + + # Apply the same rewrite to the corresponding album field. + if fieldname in corresponding_album_fields: + album_fieldname = corresponding_album_fields[fieldname] + simple_rules[album_fieldname].append((pattern, value)) else: # Advanced syntax match = rule["match"] @@ -160,18 +169,11 @@ class AdvancedRewritePlugin(BeetsPlugin): ) advanced_rules[fieldname].append((query, replacement)) - # Special case for the artist(s) field: - # apply the same rewrite for "albumartist(s)" as well. - if fieldname == "artist": - advanced_rules["albumartist"].append( - (query, replacement) - ) - elif fieldname == "artists": - advanced_rules["albumartists"].append( - (query, replacement) - ) - elif fieldname == "artist_sort": - advanced_rules["albumartist_sort"].append( + + # Apply the same rewrite to the corresponding album field. + if fieldname in corresponding_album_fields: + album_fieldname = corresponding_album_fields[fieldname] + advanced_rules[album_fieldname].append( (query, replacement) ) From 385c05f98e098efc271e13e7719d455826b9ed87 Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Fri, 15 Dec 2023 22:07:46 +0100 Subject: [PATCH 19/21] smartplaylist: change option --extm3u to --output The boolean flags `--extm3u` and `--no-extm3u` are replaced with a string option `--output=m3u|m3u8`. This reduces the amount of options and allows to evolve the CLI to support more playlist output formats in the future (e.g. JSON) without polluting the CLI at that point. --- beetsplug/smartplaylist.py | 29 ++++++++++++++--------------- docs/changelog.rst | 2 +- docs/plugins/smartplaylist.rst | 4 ++-- test/plugins/test_smartplaylist.py | 4 ++-- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index ab561e094..120361d31 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -49,7 +49,7 @@ class SmartPlaylistPlugin(BeetsPlugin): "prefix": "", "urlencode": False, "pretend_paths": False, - "extm3u": False, + "output": "m3u", } ) @@ -91,7 +91,7 @@ class SmartPlaylistPlugin(BeetsPlugin): dest="relative_to", metavar="PATH", type="string", - help="Generate playlist item paths relative to this path.", + help="generate playlist item paths relative to this path.", ) spl_update.parser.add_option( "--prefix", @@ -102,7 +102,7 @@ class SmartPlaylistPlugin(BeetsPlugin): "--forward-slash", action="store_true", dest="forward_slash", - help="Force forward slash in paths within playlists.", + help="force forward slash in paths within playlists.", ) spl_update.parser.add_option( "--urlencode", @@ -110,15 +110,9 @@ class SmartPlaylistPlugin(BeetsPlugin): help="URL-encode all paths.", ) spl_update.parser.add_option( - "--extm3u", - action="store_true", - help="generate extm3u/m3u8 playlists.", - ) - spl_update.parser.add_option( - "--no-extm3u", - action="store_false", - dest="extm3u", - help="generate extm3u/m3u8 playlists.", + "--output", + type="string", + help="specify the playlist format: m3u|m3u8.", ) spl_update.func = self.update_cmd return [spl_update] @@ -299,9 +293,14 @@ class SmartPlaylistPlugin(BeetsPlugin): os.path.join(playlist_dir, bytestring_path(m3u)) ) mkdirall(m3u_path) - extm3u = self.config["extm3u"] + pl_format = self.config["output"].get() + if pl_format != "m3u" and pl_format != "m3u8": + msg = "Unsupported output format '{}' provided! " + msg += "Supported: m3u, m3u8" + raise Exception(msg.format(pl_format)) + m3u8 = pl_format == "m3u8" with open(syspath(m3u_path), "wb") as f: - if extm3u: + if m3u8: f.write(b"#EXTM3U\n") for entry in m3us[m3u]: path = entry["path"] @@ -311,7 +310,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if self.config["urlencode"]: path = bytestring_path(pathname2url(path)) comment = "" - if extm3u: + if m3u8: comment = "#EXTINF:{},{} - {}\n".format( int(item.length), item.artist, item.title ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3295fb5d3..c88a10092 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,7 +148,7 @@ New features: `synced` option to prefer synced lyrics over plain lyrics. * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. * :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. -* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.extm3u`. +* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.output`. * :doc:`/plugins/smartplaylist`: Expose config options as CLI options. Bug fixes: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 1d4de4eb5..365b5af32 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -118,9 +118,9 @@ other configuration options are: - **urlencode**: URL-encode all paths. Default: ``no``. - **pretend_paths**: When running with ``--pretend``, show the actual file paths that will be written to the m3u file. Default: ``false``. -- **extm3u**: Generate extm3u/m3u8 playlists. Default ``ǹo``. +- **output**: Specify the playlist format: m3u|m3u8. Default ``m3u``. For many configuration options, there is a corresponding CLI option, e.g. ``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``, -``--urlencode``, ``--extm3u``, ``--pretend-paths``. +``--urlencode``, ``--output``, ``--pretend-paths``. CLI options take precedence over those specified within the configuration file. diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index f36601267..96eac625f 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -191,7 +191,7 @@ class SmartPlaylistTest(_common.TestCase): self.assertEqual(content, b"/tagada.mp3\n") - def test_playlist_update_extm3u(self): + def test_playlist_update_output_m3u8(self): spl = SmartPlaylistPlugin() i = MagicMock() @@ -215,7 +215,7 @@ class SmartPlaylistTest(_common.TestCase): spl._matched_playlists = [pl] dir = bytestring_path(mkdtemp()) - config["smartplaylist"]["extm3u"] = True + config["smartplaylist"]["output"] = "m3u8" config["smartplaylist"]["prefix"] = "http://beets:8337/files" config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = py3_path(dir) From 58e5b029297719d9411b0122df47e3682768b062 Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Thu, 14 Dec 2023 23:59:21 +0100 Subject: [PATCH 20/21] smartplaylist: add --uri-format option Beets web API already allows remote players to access audio files but it doesn't provide a way to expose the playlists defined using the smartplaylist plugin. Now the smartplaylist plugin provides an option to generate ID-based item URIs/URLs instead of paths. Once playlists are generated this way, they can be served using a regular HTTP server such as nginx. To provide sufficient flexibility for various ways of integrating beets remotely (e.g. beets API, beets API with context path, AURA API, mopidy resource URI, etc), the new option has been defined as a template with an `$id` placeholder (assuming each remote integration requires a different path schema but they all rely on using the beets item `id` as identifier/path segment). To prevent local path-related plugin configuration from leaking into a HTTP URL-based playlist generation (invoked with CLI option in addition to the local playlists generated into another directory), setting the new option makes the plugin ignore the other path-related options `prefix`, `relative_to`, `forward_slash` and `urlencode`. Usage examples: * `beet splupdate --uri-format 'http://beets:8337/item/$id/file'` (for beets web API) * `beet splupdate --uri-format 'http://beets:8337/aura/tracks/$id/audio'` (for AURA API) (While it was already possible to generate playlists containing HTTP URLs previously using the `prefix` option, it did not allow to generate ID-based URLs pointing to the beets web API but required to expose the audio files using a web server directly and refer to them using their file system `$path`.) Relates to #5037 --- beetsplug/smartplaylist.py | 38 ++++++++++++++++--------- docs/changelog.rst | 3 +- docs/plugins/smartplaylist.rst | 6 +++- test/plugins/test_smartplaylist.py | 45 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 120361d31..12a1c9218 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -45,6 +45,7 @@ class SmartPlaylistPlugin(BeetsPlugin): "playlist_dir": ".", "auto": True, "playlists": [], + "uri_format": None, "forward_slash": False, "prefix": "", "urlencode": False, @@ -109,6 +110,12 @@ class SmartPlaylistPlugin(BeetsPlugin): action="store_true", help="URL-encode all paths.", ) + spl_update.parser.add_option( + "--uri-format", + dest="uri_format", + type="string", + help="playlist item URI template, e.g. http://beets:8337/item/$id/file.", + ) spl_update.parser.add_option( "--output", type="string", @@ -247,6 +254,8 @@ class SmartPlaylistPlugin(BeetsPlugin): playlist_dir = self.config["playlist_dir"].as_filename() playlist_dir = bytestring_path(playlist_dir) + tpl = self.config["uri_format"].get() + prefix = bytestring_path(self.config["prefix"].as_str()) relative_to = self.config["relative_to"].get() if relative_to: relative_to = normpath(relative_to) @@ -275,18 +284,26 @@ class SmartPlaylistPlugin(BeetsPlugin): m3u_name = sanitize_path(m3u_name, lib.replacements) if m3u_name not in m3us: m3us[m3u_name] = [] - item_path = item.path - if relative_to: - item_path = os.path.relpath(item.path, relative_to) - if item_path not in m3us[m3u_name]: - m3us[m3u_name].append({"item": item, "path": item_path}) + item_uri = item.path + if tpl: + item_uri = tpl.replace("$id", str(item.id)).encode("utf-8") + else: + if relative_to: + item_uri = os.path.relpath(item_uri, relative_to) + if self.config["forward_slash"].get(): + item_uri = path_as_posix(item_uri) + if self.config["urlencode"]: + item_uri = bytestring_path(pathname2url(item_uri)) + item_uri = prefix + item_uri + + if item_uri not in m3us[m3u_name]: + m3us[m3u_name].append({"item": item, "uri": item_uri}) if pretend and self.config["pretend_paths"]: - print(displayable_path(item_path)) + print(displayable_path(item_uri)) elif pretend: print(item) if not pretend: - prefix = bytestring_path(self.config["prefix"].as_str()) # Write all of the accumulated track lists to files. for m3u in m3us: m3u_path = normpath( @@ -303,18 +320,13 @@ class SmartPlaylistPlugin(BeetsPlugin): if m3u8: f.write(b"#EXTM3U\n") for entry in m3us[m3u]: - path = entry["path"] item = entry["item"] - if self.config["forward_slash"].get(): - path = path_as_posix(path) - if self.config["urlencode"]: - path = bytestring_path(pathname2url(path)) comment = "" if m3u8: comment = "#EXTINF:{},{} - {}\n".format( int(item.length), item.artist, item.title ) - f.write(comment.encode("utf-8") + prefix + path + b"\n") + f.write(comment.encode("utf-8") + entry["uri"] + b"\n") # Send an event when playlists were updated. send_event("smartplaylist_update") diff --git a/docs/changelog.rst b/docs/changelog.rst index c88a10092..68d64d8bf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,8 +148,9 @@ New features: `synced` option to prefer synced lyrics over plain lyrics. * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. * :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. -* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.output`. * :doc:`/plugins/smartplaylist`: Expose config options as CLI options. +* :doc:`/plugins/smartplaylist`: Add new option `smartplaylist.output`. +* :doc:`/plugins/smartplaylist`: Add new option `smartplaylist.uri_format`. Bug fixes: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 365b5af32..a40d18882 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -118,9 +118,13 @@ other configuration options are: - **urlencode**: URL-encode all paths. Default: ``no``. - **pretend_paths**: When running with ``--pretend``, show the actual file paths that will be written to the m3u file. Default: ``false``. +- **uri_format**: Template with an ``$id`` placeholder used generate a + playlist item URI, e.g. ``http://beets:8337/item/$id/file``. + When this option is specified, the local path-related options ``prefix``, + ``relative_to``, ``forward_slash`` and ``urlencode`` are ignored. - **output**: Specify the playlist format: m3u|m3u8. Default ``m3u``. For many configuration options, there is a corresponding CLI option, e.g. ``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``, -``--urlencode``, ``--output``, ``--pretend-paths``. +``--urlencode``, ``--uri-format``, ``--output``, ``--pretend-paths``. CLI options take precedence over those specified within the configuration file. diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 96eac625f..921ae815e 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -241,6 +241,51 @@ class SmartPlaylistTest(_common.TestCase): + b"http://beets:8337/files/tagada.mp3\n", ) + def test_playlist_update_uri_format(self): + spl = SmartPlaylistPlugin() + + i = MagicMock() + type(i).id = PropertyMock(return_value=3) + type(i).path = PropertyMock(return_value=b"/tagada.mp3") + i.evaluate_template.side_effect = lambda pl, _: pl.replace( + b"$title", b"ta:ga:da" + ).decode() + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.items.return_value = [i] + lib.albums.return_value = [] + + q = Mock() + a_q = Mock() + pl = b"$title-my.m3u", (q, None), (a_q, None) + spl._matched_playlists = [pl] + + dir = bytestring_path(mkdtemp()) + tpl = "http://beets:8337/item/$id/file" + config["smartplaylist"]["uri_format"] = tpl + config["smartplaylist"]["playlist_dir"] = py3_path(dir) + # The following options should be ignored when uri_format is set + config["smartplaylist"]["relative_to"] = "/data" + config["smartplaylist"]["prefix"] = "/prefix" + config["smartplaylist"]["urlencode"] = True + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + lib.items.assert_called_once_with(q, None) + lib.albums.assert_called_once_with(a_q, None) + + m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u") + self.assertExists(m3u_filepath) + with open(syspath(m3u_filepath), "rb") as f: + content = f.read() + rmtree(syspath(dir)) + + self.assertEqual(content, b"http://beets:8337/item/3/file\n") + class SmartPlaylistCLITest(_common.TestCase, TestHelper): def setUp(self): From e64ee0b0cd38c205a06c366a9fbeeb65b273b257 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Sat, 16 Dec 2023 16:38:32 +0100 Subject: [PATCH 21/21] beetsplug: Error out on conflicts in template functions Raises an exception if multiple plugins provide template functions for the same field. Closes #5002, supersedes #5003. --- beets/plugins.py | 22 ++++++++++++++++++---- docs/changelog.rst | 9 +++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 270da9751..e1ac7d618 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -444,14 +444,29 @@ def import_stages(): # New-style (lazy) plugin-provided fields. +def _check_conflicts_and_merge(plugin, plugin_funcs, funcs): + """Check the provided template functions for conflicts and merge into funcs. + + Raises a `PluginConflictException` if a plugin defines template functions + for fields that another plugin has already defined template functions for. + """ + if plugin_funcs: + if not plugin_funcs.keys().isdisjoint(funcs.keys()): + conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) + raise PluginConflictException( + f"Plugin {plugin.name} defines template functions for " + f"{conflicted_fields} that conflict with another plugin." + ) + funcs.update(plugin_funcs) + + def item_field_getters(): """Get a dictionary mapping field names to unary functions that compute the field's value. """ funcs = {} for plugin in find_plugins(): - if plugin.template_fields: - funcs.update(plugin.template_fields) + _check_conflicts_and_merge(plugin, plugin.template_fields, funcs) return funcs @@ -459,8 +474,7 @@ def album_field_getters(): """As above, for album fields.""" funcs = {} for plugin in find_plugins(): - if plugin.album_template_fields: - funcs.update(plugin.album_template_fields) + _check_conflicts_and_merge(plugin, plugin.album_template_fields, funcs) return funcs diff --git a/docs/changelog.rst b/docs/changelog.rst index c88a10092..7293a08b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -277,6 +277,15 @@ Bug fixes: * Fix bug regarding displaying tracks that have been changed not being displayed unless the detail configuration is enabled. +For plugin developers: + +* beets now explicitly prevents multiple plugins to define replacement + functions for the same field. When previously defining `template_fields` + for the same field in two plugins, the last loaded plugin would silently + overwrite the function defined by the other plugin. + Now, beets will raise an exception when this happens. + :bug:`5002` + For packagers: * As noted above, the minimum Python version is now 3.7.