diff --git a/beetsplug/convert.py b/beetsplug/convert.py index e72f8c75a..74ced8ae3 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -95,12 +95,18 @@ def in_no_convert(item: Item) -> bool: return False -def should_transcode(item, fmt): +def should_transcode(item, fmt, force: bool = False): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). + + If ``force`` is True, safety checks like ``no_convert`` and + ``never_convert_lossy_files`` are ignored and the item is always + transcoded. """ + if force: + return True if in_no_convert(item) or ( - config["convert"]["never_convert_lossy_files"] + config["convert"]["never_convert_lossy_files"].get(bool) and item.format.lower() not in LOSSLESS_FORMATS ): return False @@ -236,6 +242,16 @@ class ConvertPlugin(BeetsPlugin): drive, relative paths pointing to media files will be used.""", ) + cmd.parser.add_option( + "-F", + "--force", + action="store_true", + dest="force", + help=( + "force transcoding. Ignores no_convert, " + "never_convert_lossy_files, and max_bitrate" + ), + ) cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] @@ -259,6 +275,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) = self._get_opts_and_config(empty_opts) items = task.imported_items() @@ -272,6 +289,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) # Utilities converted from functions to methods on logging overhaul @@ -347,6 +365,7 @@ class ConvertPlugin(BeetsPlugin): pretend=False, link=False, hardlink=False, + force=False, ): """A pipeline thread that converts `Item` objects from a library. @@ -372,11 +391,11 @@ class ConvertPlugin(BeetsPlugin): if keep_new: original = dest converted = item.path - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): converted = replace_ext(converted, ext) else: original = item.path - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): dest = replace_ext(dest, ext) converted = dest @@ -406,7 +425,7 @@ class ConvertPlugin(BeetsPlugin): ) util.move(item.path, original) - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): linked = False try: self.encode(command, original, converted, pretend) @@ -577,6 +596,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) = self._get_opts_and_config(opts) if opts.album: @@ -613,6 +633,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) if playlist: @@ -735,7 +756,7 @@ class ConvertPlugin(BeetsPlugin): else: hardlink = self.config["hardlink"].get(bool) link = self.config["link"].get(bool) - + force = getattr(opts, "force", False) return ( dest, threads, @@ -745,6 +766,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) def _parallel_convert( @@ -758,13 +780,21 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ): """Run the convert_item function for every items on as many thread as defined in threads """ convert = [ self.convert_item( - dest, keep_new, path_formats, fmt, pretend, link, hardlink + dest, + keep_new, + path_formats, + fmt, + pretend, + link, + hardlink, + force, ) for _ in range(threads) ] diff --git a/docs/changelog.rst b/docs/changelog.rst index 76951a541..b9a5c1f3f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`/plugins/convert`: ``force`` can be passed to override checks like + no_convert, never_convert_lossy_files, same format, and max_bitrate - :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve differences in metadata source styles. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index ecf60a85b..14b545b28 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -51,6 +51,11 @@ instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art embedding is disabled for files that are linked. Refer to the ``link`` and ``hardlink`` options below. +The ``-F`` (or ``--force``) option forces transcoding even when safety options +such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would +normally cause a file to be copied or skipped instead. This can be combined with +``--format`` to explicitly transcode lossy inputs to a chosen target format. + The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8 playlist file in the destination folder given by the ``-d`` (``--dest``) option or the ``dest`` configuration. The path to the playlist file can either be @@ -104,15 +109,21 @@ The available options are: with high bitrates, even if they are already in the same format as the output. Note that this does not guarantee that all converted files will have a lower bitrate---that depends on the encoder and its configuration. Default: none. + This option will be overridden by the ``--force`` flag - **no_convert**: Does not transcode items matching the query string provided (see :doc:`/reference/query`). For example, to not convert AAC or WMA formats, you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only want to transcode WMA format, you can use a negative query, e.g., - ``^path::\.(wma)$``, to not convert any other format except WMA. + ``^path::\.(wma)$``, to not convert any other format except WMA. This option + will be overridden by the ``--force`` flag - **never_convert_lossy_files**: Cross-conversions between lossy codecs---such as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality even further. If set to ``yes``, lossy files are always copied. Default: - ``no``. + ``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for + example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied + or linked as-is. To explicitly transcode lossy files in spite of this, use the + ``--force`` option with the ``convert`` command (optionally together with + ``--format`` to choose a target format) - **paths**: The directory structure and naming scheme for the converted files. Uses the same format as the top-level ``paths`` section (see :ref:`path-format-config`). Default: Reuse your top-level path format diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 1452686a7..9ae0ebf6d 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -236,6 +236,16 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.convert_dest / "converted.ogg", "ogg" ) + def test_force_overrides_max_bitrate_and_same_formats(self): + self.config["convert"]["max_bitrate"] = 5000 + self.config["convert"]["format"] = "ogg" + + with control_stdin("y"): + self.run_convert("--force") + + converted = self.convert_dest / "converted.ogg" + assert self.file_endswith(converted, "ogg") + def test_transcode_when_maxbr_set_low_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5 self.config["convert"]["format"] = "ogg" @@ -260,6 +270,21 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.run_convert("--playlist", "playlist.m3u8", "--pretend") assert not (self.convert_dest / "playlist.m3u8").exists() + def test_force_overrides_no_convert(self): + self.config["convert"]["formats"]["opus"] = { + "command": self.tagged_copy_cmd("opus"), + "extension": "ops", + } + self.config["convert"]["no_convert"] = "format:ogg" + + [item] = self.add_item_fixtures(ext="ogg") + + with control_stdin("y"): + self.run_convert_path(item, "--format", "opus", "--force") + + converted = self.convert_dest / "converted.ops" + assert self.file_endswith(converted, "opus") + @_common.slow_test() class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): @@ -301,6 +326,19 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): converted = self.convert_dest / "converted.ogg" assert not self.file_endswith(converted, "mp3") + def test_force_overrides_never_convert_lossy_files(self): + self.config["convert"]["formats"]["opus"] = { + "command": self.tagged_copy_cmd("opus"), + "extension": "ops", + } + [item] = self.add_item_fixtures(ext="ogg") + + with control_stdin("y"): + self.run_convert_path(item, "--format", "opus", "--force") + + converted = self.convert_dest / "converted.ops" + assert self.file_endswith(converted, "opus") + class TestNoConvert: """Test the effect of the `no_convert` option."""