From e3e47618f9b9d97eb5a37d17430a10a7460be47a Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Sun, 14 Apr 2024 17:08:03 +0200 Subject: [PATCH 01/11] Fix extension substitution inside path of the exported playlist Before this, the exported playlist contained relative paths pointing to the converted files BUT the extension were not substituted comparing to before and the after the conversion. Therefore, running the playlist will fail for files which have been converted and where extension have changed. --- beetsplug/convert.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 7586c2a1b..c46b2fc85 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -614,6 +614,8 @@ class ConvertPlugin(BeetsPlugin): items, ) + # If the user supplied a playlist name, create a playlist containing + # all converted titles using this name. if playlist: # Playlist paths are understood as relative to the dest directory. pl_normpath = util.normpath(playlist) @@ -624,7 +626,15 @@ class ConvertPlugin(BeetsPlugin): # strings we get from item.destination to bytes. items_paths = [ os.path.relpath( - item.destination(basedir=dest, path_formats=path_formats), + # Substitute the before-conversion file extension by + # the after-conversion extension. + replace_ext( + item.destination( + basedir=dest, + path_formats=path_formats + ), + get_format()[1], + ), pl_dir, ) for item in items From ddddddfdb3a1ad600b79f316d50326ce3e711f07 Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Sun, 14 Apr 2024 17:09:44 +0200 Subject: [PATCH 02/11] Add some assertion for input of replace_ext() --- beetsplug/convert.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index c46b2fc85..fc9e368fa 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -49,6 +49,8 @@ def replace_ext(path, ext): The new extension must not contain a leading dot. """ + assert isinstance(path, bytes) + assert isinstance(ext, bytes) ext_dot = b"." + ext return os.path.splitext(path)[0] + ext_dot From cddfa8c6dda29e938ec798af9d5af51819f70e4a Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Sun, 14 Apr 2024 17:10:03 +0200 Subject: [PATCH 03/11] Update the changelog for the bugfix --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab0b9519d..e2e77d99e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,8 @@ Bug fixes: incorrectly removed parts of user-configured path formats that followed a dot (**.**). :bug:`5771` +* :doc:`/plugins/convert`: Fix extension substitution inside path of the + exported playlist. For packagers: From dad1b2e4e1eadeb12e2d4d5e06e9f0517aacd7cf Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Wed, 3 Jul 2024 12:54:39 +0200 Subject: [PATCH 04/11] Revert "Add some assertion for input of replace_ext()" This reverts commit 52b639b4ee95736bb128876a8e4bd6a7046b06a6. --- beetsplug/convert.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index fc9e368fa..c46b2fc85 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -49,8 +49,6 @@ def replace_ext(path, ext): The new extension must not contain a leading dot. """ - assert isinstance(path, bytes) - assert isinstance(ext, bytes) ext_dot = b"." + ext return os.path.splitext(path)[0] + ext_dot From 953cb75712e6e1d8edc90b53c2161bc3e4f992ca Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Wed, 3 Jul 2024 14:21:34 +0200 Subject: [PATCH 05/11] Add a unit test for playlist file extension --- test/plugins/test_convert.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 6dd28337a..ce3f159e6 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -293,6 +293,17 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") assert not os.path.exists(m3u_created) + def test_playlist_ext(self): + """Test correct extension of file inside the playlist when format conversion occurs.""" + # We expect a converted file with the MP3 extension. + self.config["convert"]["format"] = "mp3" + with control_stdin("y"): + self.run_convert("--playlist", "playlist.m3u8") + # Check playlist content. + m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") + with open(m3u_created, "r") as m3u_file: + self.assertTrue(m3u_file.readline() == "#EXTM3U\n") + self.assertTrue(m3u_file.readline() == "converted.mp3\n") @_common.slow_test() class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): From d5a112f952b2b7bc94614fb718e80a8ed98c123e Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Wed, 3 Jul 2024 14:37:55 +0200 Subject: [PATCH 06/11] Fix formatting error --- test/plugins/test_convert.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index ce3f159e6..92033a4c3 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -305,6 +305,7 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.assertTrue(m3u_file.readline() == "#EXTM3U\n") self.assertTrue(m3u_file.readline() == "converted.mp3\n") + @_common.slow_test() class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): """Test the effect of the `never_convert_lossy_files` option.""" From 7cab10b19103e0a63985613353124d597060085a Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Wed, 3 Jul 2024 14:38:50 +0200 Subject: [PATCH 07/11] Fix linter error --- test/plugins/test_convert.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 92033a4c3..b9abe8b43 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -294,7 +294,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): assert not os.path.exists(m3u_created) def test_playlist_ext(self): - """Test correct extension of file inside the playlist when format conversion occurs.""" + """Test correct extension of file inside the playlist when format + conversion occurs.""" # We expect a converted file with the MP3 extension. self.config["convert"]["format"] = "mp3" with control_stdin("y"): From cf2cd36d615acd25e75363a9612a44eb9721604b Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Sat, 8 Feb 2025 15:54:18 +0100 Subject: [PATCH 08/11] [test_convert] Use a regular assert Fix linting error Ruff (PT009) --- test/plugins/test_convert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index b9abe8b43..a748433ab 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -303,8 +303,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): # Check playlist content. m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") with open(m3u_created, "r") as m3u_file: - self.assertTrue(m3u_file.readline() == "#EXTM3U\n") - self.assertTrue(m3u_file.readline() == "converted.mp3\n") + assert m3u_file.readline() == "#EXTM3U\n" + assert m3u_file.readline() == "converted.mp3\n" @_common.slow_test() From f0c2c8e649be41a8463caeb66dc2bb2ef982425f Mon Sep 17 00:00:00 2001 From: Pierre Ayoub Date: Wed, 14 May 2025 10:12:38 +0200 Subject: [PATCH 09/11] chore(beetsplug/convert): ruff format --- beetsplug/convert.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index c46b2fc85..0947d38cf 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -630,8 +630,7 @@ class ConvertPlugin(BeetsPlugin): # the after-conversion extension. replace_ext( item.destination( - basedir=dest, - path_formats=path_formats + basedir=dest, path_formats=path_formats ), get_format()[1], ), From bc9213a4ed1f2f54e422387e5f61791cd76584be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 4 Mar 2026 15:28:30 +0000 Subject: [PATCH 10/11] Fix lint issues --- test/plugins/test_convert.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 6673312e2..e91d0de01 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -293,13 +293,13 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): conversion occurs.""" # We expect a converted file with the MP3 extension. self.config["convert"]["format"] = "mp3" - with control_stdin("y"): - self.run_convert("--playlist", "playlist.m3u8") - # Check playlist content. - m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") - with open(m3u_created, "r") as m3u_file: - assert m3u_file.readline() == "#EXTM3U\n" - assert m3u_file.readline() == "converted.mp3\n" + self.io.addinput("y") + self.run_convert("--playlist", "playlist.m3u8") + # Check playlist content. + m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") + with open(m3u_created) as m3u_file: + assert m3u_file.readline() == "#EXTM3U\n" + assert m3u_file.readline() == "converted.mp3\n" @_common.slow_test() From a5a977593067245da42e3d084eed54d809994e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 4 Mar 2026 15:38:33 +0000 Subject: [PATCH 11/11] convert: generate playlist entries from effective output paths Build playlist paths using the selected format (`--format`/config), and only replace extensions when the destination file is actually transcoded. Precompute playlist entries before conversion runs so `--keep-new` does not pick up mutated item paths and produce mismatched extensions. Add/expand convert CLI tests to cover: - config format playlist extension - `--format` override playlist extension - no-transcode (`no_convert`) playlist extension - `--keep-new` destination playlist path behavior --- beetsplug/convert.py | 44 ++++++++++++++++++------------------ test/plugins/test_convert.py | 29 +++++++++++++++--------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index c0a5cdf84..6f4b3e50e 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -627,6 +627,28 @@ class ConvertPlugin(BeetsPlugin): album, dest, path_formats, pretend, link, hardlink ) + # If the user supplied a playlist name, create a playlist for files + # copied to the destination. + pl_normpath = None + items_paths = None + if playlist: + _, ext = get_format(fmt) + # Playlist paths are understood as relative to the dest directory. + pl_normpath = util.normpath(playlist) + pl_dir = os.path.dirname(pl_normpath) + items_paths = [] + for item in items: + item_path = item.destination( + basedir=dest, path_formats=path_formats + ) + + # When keeping new files in the library, destination paths + # keep original files and extensions. + if not opts.keep_new and should_transcode(item, fmt, force): + item_path = replace_ext(item_path, ext) + + items_paths.append(os.path.relpath(item_path, pl_dir)) + self._parallel_convert( dest, opts.keep_new, @@ -640,30 +662,8 @@ class ConvertPlugin(BeetsPlugin): force, ) - # If the user supplied a playlist name, create a playlist containing - # all converted titles using this name. if playlist: - # Playlist paths are understood as relative to the dest directory. - pl_normpath = util.normpath(playlist) - pl_dir = os.path.dirname(pl_normpath) self._log.info("Creating playlist file {}", pl_normpath) - # Generates a list of paths to media files, ensures the paths are - # relative to the playlist's location and translates the unicode - # strings we get from item.destination to bytes. - items_paths = [ - os.path.relpath( - # Substitute the before-conversion file extension by - # the after-conversion extension. - replace_ext( - item.destination( - basedir=dest, path_formats=path_formats - ), - get_format()[1], - ), - pl_dir, - ) - for item in items - ] if not pretend: m3ufile = M3UFile(playlist) m3ufile.set_contents(items_paths) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index e91d0de01..ab3c82248 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -288,18 +288,25 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): converted = self.convert_dest / "converted.ops" assert self.file_endswith(converted, "opus") - def test_playlist_ext(self): - """Test correct extension of file inside the playlist when format - conversion occurs.""" - # We expect a converted file with the MP3 extension. - self.config["convert"]["format"] = "mp3" + def assert_playlist_entry(self, expected_entry, *args): self.io.addinput("y") - self.run_convert("--playlist", "playlist.m3u8") - # Check playlist content. - m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8") - with open(m3u_created) as m3u_file: - assert m3u_file.readline() == "#EXTM3U\n" - assert m3u_file.readline() == "converted.mp3\n" + self.run_convert(*args, "--playlist", "playlist.m3u8") + lines = (self.convert_dest / "playlist.m3u8").read_text().splitlines() + assert lines[0] == "#EXTM3U" + assert lines[1] == expected_entry + + def test_playlist_entry_uses_config_format(self): + self.assert_playlist_entry("converted.mp3") + + def test_playlist_entry_uses_cli_format(self): + self.assert_playlist_entry("converted.ops", "--format", "opus") + + def test_playlist_entry_keeps_original_extension_when_not_transcoded(self): + self.config["convert"]["no_convert"] = "format:ogg" + self.assert_playlist_entry("converted.ogg") + + def test_playlist_entry_keep_new_points_to_destination_file(self): + self.assert_playlist_entry("converted.ogg", "--keep-new") @_common.slow_test()