Skip to content

Commit 2bd77b9

Browse files
GuyBloomJOJ0
andauthored
Fix convert --format with never_convert_lossy_files (#6171)
## Description Fixes #5625 When `convert.never_convert_lossy_files` is enabled, `beet convert` was ignoring the explicit `--format` option and just copying the lossy files without transcoding them. For example: - `beet convert format:mp3 --format opus` would still produce MP3 files instead of OPUS. Change: - Allows to override options `never_convert_lossy_files`, `max_bitrate` or `no_convert` for `beet convert` as well as trying to convert to the same format as existing already with a new option `--force`. That way, for example lossy files selected by the query are transcoded to the requested format anyway. - Keeps existing behavior for automatic conversion on import (no CLI override there). - Adds tests to cover checking whether `--force` correctly overrides settings or CLI options. - Documents the behavior in the convert plugin docs Co-authored-by: J0J0 Todos <jojo@peek-a-boo.at>
1 parent 5393127 commit 2bd77b9

File tree

4 files changed

+90
-9
lines changed

4 files changed

+90
-9
lines changed

beetsplug/convert.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
9595
return False
9696

9797

98-
def should_transcode(item, fmt):
98+
def should_transcode(item, fmt, force: bool = False):
9999
"""Determine whether the item should be transcoded as part of
100100
conversion (i.e., its bitrate is high or it has the wrong format).
101+
102+
If ``force`` is True, safety checks like ``no_convert`` and
103+
``never_convert_lossy_files`` are ignored and the item is always
104+
transcoded.
101105
"""
106+
if force:
107+
return True
102108
if in_no_convert(item) or (
103-
config["convert"]["never_convert_lossy_files"]
109+
config["convert"]["never_convert_lossy_files"].get(bool)
104110
and item.format.lower() not in LOSSLESS_FORMATS
105111
):
106112
return False
@@ -236,6 +242,16 @@ def commands(self):
236242
drive, relative paths pointing to media files
237243
will be used.""",
238244
)
245+
cmd.parser.add_option(
246+
"-F",
247+
"--force",
248+
action="store_true",
249+
dest="force",
250+
help=(
251+
"force transcoding. Ignores no_convert, "
252+
"never_convert_lossy_files, and max_bitrate"
253+
),
254+
)
239255
cmd.parser.add_album_option()
240256
cmd.func = self.convert_func
241257
return [cmd]
@@ -259,6 +275,7 @@ def auto_convert_keep(self, config, task):
259275
hardlink,
260276
link,
261277
playlist,
278+
force,
262279
) = self._get_opts_and_config(empty_opts)
263280

264281
items = task.imported_items()
@@ -272,6 +289,7 @@ def auto_convert_keep(self, config, task):
272289
hardlink,
273290
threads,
274291
items,
292+
force,
275293
)
276294

277295
# Utilities converted from functions to methods on logging overhaul
@@ -347,6 +365,7 @@ def convert_item(
347365
pretend=False,
348366
link=False,
349367
hardlink=False,
368+
force=False,
350369
):
351370
"""A pipeline thread that converts `Item` objects from a
352371
library.
@@ -372,11 +391,11 @@ def convert_item(
372391
if keep_new:
373392
original = dest
374393
converted = item.path
375-
if should_transcode(item, fmt):
394+
if should_transcode(item, fmt, force):
376395
converted = replace_ext(converted, ext)
377396
else:
378397
original = item.path
379-
if should_transcode(item, fmt):
398+
if should_transcode(item, fmt, force):
380399
dest = replace_ext(dest, ext)
381400
converted = dest
382401

@@ -406,7 +425,7 @@ def convert_item(
406425
)
407426
util.move(item.path, original)
408427

409-
if should_transcode(item, fmt):
428+
if should_transcode(item, fmt, force):
410429
linked = False
411430
try:
412431
self.encode(command, original, converted, pretend)
@@ -577,6 +596,7 @@ def convert_func(self, lib, opts, args):
577596
hardlink,
578597
link,
579598
playlist,
599+
force,
580600
) = self._get_opts_and_config(opts)
581601

582602
if opts.album:
@@ -613,6 +633,7 @@ def convert_func(self, lib, opts, args):
613633
hardlink,
614634
threads,
615635
items,
636+
force,
616637
)
617638

618639
if playlist:
@@ -735,7 +756,7 @@ def _get_opts_and_config(self, opts):
735756
else:
736757
hardlink = self.config["hardlink"].get(bool)
737758
link = self.config["link"].get(bool)
738-
759+
force = getattr(opts, "force", False)
739760
return (
740761
dest,
741762
threads,
@@ -745,6 +766,7 @@ def _get_opts_and_config(self, opts):
745766
hardlink,
746767
link,
747768
playlist,
769+
force,
748770
)
749771

750772
def _parallel_convert(
@@ -758,13 +780,21 @@ def _parallel_convert(
758780
hardlink,
759781
threads,
760782
items,
783+
force,
761784
):
762785
"""Run the convert_item function for every items on as many thread as
763786
defined in threads
764787
"""
765788
convert = [
766789
self.convert_item(
767-
dest, keep_new, path_formats, fmt, pretend, link, hardlink
790+
dest,
791+
keep_new,
792+
path_formats,
793+
fmt,
794+
pretend,
795+
link,
796+
hardlink,
797+
force,
768798
)
769799
for _ in range(threads)
770800
]

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ New features:
2727
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
2828
MusicBrainz pseudo-releases as recommendations during import.
2929
- Added support for Python 3.13.
30+
- :doc:`/plugins/convert`: ``force`` can be passed to override checks like
31+
no_convert, never_convert_lossy_files, same format, and max_bitrate
3032
- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to
3133
resolve differences in metadata source styles.
3234

docs/plugins/convert.rst

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art
5151
embedding is disabled for files that are linked. Refer to the ``link`` and
5252
``hardlink`` options below.
5353

54+
The ``-F`` (or ``--force``) option forces transcoding even when safety options
55+
such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would
56+
normally cause a file to be copied or skipped instead. This can be combined with
57+
``--format`` to explicitly transcode lossy inputs to a chosen target format.
58+
5459
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
5560
playlist file in the destination folder given by the ``-d`` (``--dest``) option
5661
or the ``dest`` configuration. The path to the playlist file can either be
@@ -104,15 +109,21 @@ The available options are:
104109
with high bitrates, even if they are already in the same format as the output.
105110
Note that this does not guarantee that all converted files will have a lower
106111
bitrate---that depends on the encoder and its configuration. Default: none.
112+
This option will be overridden by the ``--force`` flag
107113
- **no_convert**: Does not transcode items matching the query string provided
108114
(see :doc:`/reference/query`). For example, to not convert AAC or WMA formats,
109115
you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only
110116
want to transcode WMA format, you can use a negative query, e.g.,
111-
``^path::\.(wma)$``, to not convert any other format except WMA.
117+
``^path::\.(wma)$``, to not convert any other format except WMA. This option
118+
will be overridden by the ``--force`` flag
112119
- **never_convert_lossy_files**: Cross-conversions between lossy codecs---such
113120
as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality
114121
even further. If set to ``yes``, lossy files are always copied. Default:
115-
``no``.
122+
``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for
123+
example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied
124+
or linked as-is. To explicitly transcode lossy files in spite of this, use the
125+
``--force`` option with the ``convert`` command (optionally together with
126+
``--format`` to choose a target format)
116127
- **paths**: The directory structure and naming scheme for the converted files.
117128
Uses the same format as the top-level ``paths`` section (see
118129
:ref:`path-format-config`). Default: Reuse your top-level path format

test/plugins/test_convert.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,16 @@ def test_no_transcode_when_maxbr_set_high_and_same_formats(self):
236236
self.convert_dest / "converted.ogg", "ogg"
237237
)
238238

239+
def test_force_overrides_max_bitrate_and_same_formats(self):
240+
self.config["convert"]["max_bitrate"] = 5000
241+
self.config["convert"]["format"] = "ogg"
242+
243+
with control_stdin("y"):
244+
self.run_convert("--force")
245+
246+
converted = self.convert_dest / "converted.ogg"
247+
assert self.file_endswith(converted, "ogg")
248+
239249
def test_transcode_when_maxbr_set_low_and_same_formats(self):
240250
self.config["convert"]["max_bitrate"] = 5
241251
self.config["convert"]["format"] = "ogg"
@@ -260,6 +270,21 @@ def test_playlist_pretend(self):
260270
self.run_convert("--playlist", "playlist.m3u8", "--pretend")
261271
assert not (self.convert_dest / "playlist.m3u8").exists()
262272

273+
def test_force_overrides_no_convert(self):
274+
self.config["convert"]["formats"]["opus"] = {
275+
"command": self.tagged_copy_cmd("opus"),
276+
"extension": "ops",
277+
}
278+
self.config["convert"]["no_convert"] = "format:ogg"
279+
280+
[item] = self.add_item_fixtures(ext="ogg")
281+
282+
with control_stdin("y"):
283+
self.run_convert_path(item, "--format", "opus", "--force")
284+
285+
converted = self.convert_dest / "converted.ops"
286+
assert self.file_endswith(converted, "opus")
287+
263288

264289
@_common.slow_test()
265290
class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
@@ -301,6 +326,19 @@ def test_transcode_from_lossy_prevented(self):
301326
converted = self.convert_dest / "converted.ogg"
302327
assert not self.file_endswith(converted, "mp3")
303328

329+
def test_force_overrides_never_convert_lossy_files(self):
330+
self.config["convert"]["formats"]["opus"] = {
331+
"command": self.tagged_copy_cmd("opus"),
332+
"extension": "ops",
333+
}
334+
[item] = self.add_item_fixtures(ext="ogg")
335+
336+
with control_stdin("y"):
337+
self.run_convert_path(item, "--format", "opus", "--force")
338+
339+
converted = self.convert_dest / "converted.ops"
340+
assert self.file_endswith(converted, "opus")
341+
304342

305343
class TestNoConvert:
306344
"""Test the effect of the `no_convert` option."""

0 commit comments

Comments
 (0)