From 74a5c0484bcf82b2cd5d01b44b317a92402651de Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 13:17:13 +0100 Subject: [PATCH 1/7] Add remove_missing option to convert plugin --- beetsplug/convert.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index af1279299e..0198622446 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -165,6 +165,7 @@ def __init__(self): "album_art_maxwidth": 0, "delete_originals": False, "playlist": None, + "remove_missing": False, } ) self.early_import_stages = [self.auto_convert, self.auto_convert_keep] @@ -242,6 +243,13 @@ def commands(self): drive, relative paths pointing to media files will be used.""", ) + cmd.parser.add_option( + "-r", + "--remove-missing", + action="store_true", + help="""remove all files in the destination directory that are not + present in the library.""", + ) cmd.parser.add_option( "-F", "--force", @@ -275,6 +283,7 @@ def auto_convert_keep(self, config, task): hardlink, link, _, + _, force, ) = self._get_opts_and_config(empty_opts) @@ -600,6 +609,7 @@ def convert_func(self, lib, opts, args): hardlink, link, playlist, + remove_missing, force, ) = self._get_opts_and_config(opts) @@ -627,6 +637,9 @@ def convert_func(self, lib, opts, args): album, dest, path_formats, pretend, link, hardlink ) + if remove_missing: + self.remove_non_item_files(items, dest, fmt, pretend, opts.yes) + self._parallel_convert( dest, opts.keep_new, @@ -760,7 +773,14 @@ def _get_opts_and_config(self, opts): else: hardlink = self.config["hardlink"].get(bool) link = self.config["link"].get(bool) + + if opts.remove_missing is not None: + remove_missing = opts.remove_missing + else: + remove_missing = self.config["remove_missing"].get(bool) + force = getattr(opts, "force", False) + return ( dest, threads, @@ -770,6 +790,7 @@ def _get_opts_and_config(self, opts): hardlink, link, playlist, + remove_missing, force, ) @@ -804,3 +825,35 @@ def _parallel_convert( ] pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() + + def remove_non_item_files(self, items, dest, fmt, pretend, yes): + """ + Remove all files in the destination directory ``dest`` that do not + correspond to an item to be converted. If ``pretend=True`` it will + not actually remove any files and only print a list of files to be + deleted. If ``yes=True`` it will not ask for confirmation. + """ + _, ext = get_format(fmt) + item_destinations = { + replace_ext(item.destination(basedir=dest), ext) for item in items + } + files_to_remove = list() + + for dirpath, _, filenames in os.walk(dest): + for filename in filenames: + filepath = util.bytestring_path(os.path.join(dirpath, filename)) + if filepath not in item_destinations: + files_to_remove.append(filepath) + + if not files_to_remove: + ui.print_("No files to be removed") + return + + ui.print_("Files to be deleted in the destination folder:") + for f in files_to_remove: + ui.print_(util.displayable_path(f)) + + if (not pretend) and (yes or ui.input_yn("Delete files? (Y/n)")): + for file in files_to_remove: + util.remove(file) + self._log.info(f'Removed file "{util.displayable_path(file)}"') From 03ced870aefb076d543846575f66a25731d7d76d Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 13:17:37 +0100 Subject: [PATCH 2/7] Add remove_missing test cases --- test/plugins/test_convert.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 2a1a3b94db..be8685c1af 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -359,3 +359,35 @@ def test_no_convert_skip(self, config_value, should_skip): item = Item(format="ogg", bitrate=256) convert.config["convert"]["no_convert"] = config_value assert convert.in_no_convert(item) == should_skip + + +class ConvertRemoveMissingTest(ConvertTestCase, ConvertCommand): + "Tests the effect of the `remove_missing option`" + + def setUp(self): + super().setUp() + + self.album = self.add_album_fixture(ext="ogg") + self.item = self.album.items()[0] + + self.convert_dest = self.temp_dir_path / "convert_dest" + self.file_to_remove = self.convert_dest / "to_remove.mp3" + self.convert_dest.mkdir(parents=True) + + self.config["convert"] = { + "dest": str(self.convert_dest), + "format": "mp3", + } + + with self.file_to_remove.open("w") as f: + f.write("test") + + def test_convert_not_removemissing(self): + self.run_convert("--yes") + + assert self.file_to_remove.exists() + + def test_convert_removemissing(self): + self.run_convert("--remove-missing", "--yes") + + assert not self.file_to_remove.exists() From db34c5b955e2494ccd7c36aa7349202b8d9aaf2c Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 13:17:58 +0100 Subject: [PATCH 3/7] Add documentation for remove_missing option of convert plugin --- docs/plugins/convert.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 14b545b28d..bb760fb57d 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -69,6 +69,9 @@ Depending on the beets user's settings a generated playlist potentially could contain unicode characters. This is supported, playlists are written in `M3U8 format`_. +The ``-r`` (or ``--remove-missing``) option will remove files in the destination +folder that are not present in the library. + Configuration ------------- @@ -151,6 +154,12 @@ The available options are: as well. The final destination of the playlist file will always be relative to the destination path (``dest``, ``--dest``, ``-d``). This configuration is overridden by the ``-m`` (``--playlist``) command line option. Default: none. +- **remove_missing**: Whether or not to remove files in the destination folder + that are no longer present in the library. This means that if you removed an + item from the database that was previously converted, it will be removed in + the next run of the ``convert`` command (it will ask for confirmation unless + the ``--yes`` option is enabled). This is useful if you want to keep a + converted version of your library synced. Default: ``false``. You can also configure the format to use for transcoding (see the next section): From 802e90447aa79bb027d6c45f21ddf0b470231b50 Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 13:18:08 +0100 Subject: [PATCH 4/7] Add changelog entry for remove_missing option of convert plugin --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d59c7ba1f3..9533f0f8a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,9 @@ New features: untouched the files without. - :doc:`plugins/fish`: Filenames are now completed in more places, like after ``beet import``. +- :doc:`plugins/convert`: Added the new ``remove_missing`` configuration and + corresponding ``--remove-missing`` option to enable removing files in the + destination directory that were removed from the library. Bug fixes: From 5253508c30b0db823ad6c64c000bb360a71220b6 Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 14:44:14 +0100 Subject: [PATCH 5/7] Simplify test setup --- test/plugins/test_convert.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index be8685c1af..bfabb4571a 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -367,8 +367,7 @@ class ConvertRemoveMissingTest(ConvertTestCase, ConvertCommand): def setUp(self): super().setUp() - self.album = self.add_album_fixture(ext="ogg") - self.item = self.album.items()[0] + self.item = self.add_item(title="title", album="album", format="ogg") self.convert_dest = self.temp_dir_path / "convert_dest" self.file_to_remove = self.convert_dest / "to_remove.mp3" From aee3a558f863c9d43448f2e9be2c7283355b817b Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 15:09:11 +0100 Subject: [PATCH 6/7] Improve test coverage --- test/plugins/test_convert.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index bfabb4571a..ac1c3c6fac 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -386,7 +386,15 @@ def test_convert_not_removemissing(self): assert self.file_to_remove.exists() + def test_convert_pretend_removemissing(self): + self.run_convert("--yes", "--remove-missing", "--pretend") + + assert self.file_to_remove.exists() + def test_convert_removemissing(self): - self.run_convert("--remove-missing", "--yes") + self.run_convert("--yes", "--remove-missing") assert not self.file_to_remove.exists() + + # This should hit the case where no files to remove are present + self.run_convert("--remove-missing", "--yes") From df1f585dc87f9251963e1ff79bdfde3e9b70a136 Mon Sep 17 00:00:00 2001 From: Javier Barbero Date: Tue, 27 Jan 2026 15:56:05 +0100 Subject: [PATCH 7/7] Improve test coverage and test documentation --- test/plugins/test_convert.py | 53 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index ac1c3c6fac..7f9b96e15a 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -367,10 +367,11 @@ class ConvertRemoveMissingTest(ConvertTestCase, ConvertCommand): def setUp(self): super().setUp() - self.item = self.add_item(title="title", album="album", format="ogg") + self.item = self.add_item_fixture( + title="title", artist="artist", album="album", format="flac" + ) self.convert_dest = self.temp_dir_path / "convert_dest" - self.file_to_remove = self.convert_dest / "to_remove.mp3" self.convert_dest.mkdir(parents=True) self.config["convert"] = { @@ -378,23 +379,59 @@ def setUp(self): "format": "mp3", } - with self.file_to_remove.open("w") as f: + def create_dummy_file(self, path): + "Creates a dummy file in the conversion directory" + p = self.convert_dest / path + p.parent.mkdir(parents=True, exist_ok=True) + with p.open("w") as f: f.write("test") + return p def test_convert_not_removemissing(self): + "Test that files are not removed when the remove_missing option is not enabled" + file_to_remove = self.create_dummy_file("to_remove.mp3") + self.run_convert("--yes") - assert self.file_to_remove.exists() + assert file_to_remove.exists() def test_convert_pretend_removemissing(self): + "Test that files are not removed when the pretend flag is enabled" + file_to_remove = self.create_dummy_file("to_remove.mp3") + self.run_convert("--yes", "--remove-missing", "--pretend") - assert self.file_to_remove.exists() + assert file_to_remove.exists() + + def test_convert_removemissing_option(self): + "Test that files are removed when the remove_missing option is enabled" + file_to_remove = self.create_dummy_file("to_remove.mp3") - def test_convert_removemissing(self): self.run_convert("--yes", "--remove-missing") - assert not self.file_to_remove.exists() + assert not file_to_remove.exists() # This should hit the case where no files to remove are present - self.run_convert("--remove-missing", "--yes") + self.run_convert("--yes", "--remove-missing") + + def test_convert_removemissing_config(self): + "Test that files are removed when the remove_missing config is set to True" + file_to_remove = self.create_dummy_file("to_remove.mp3") + + self.config["convert"]["remove_missing"] = True + self.run_convert("--yes") + + assert not file_to_remove.exists() + + def test_convert_dont_remove_present(self): + """Test that already converted files are not removed when the + remove_missing option is enabled""" + # This file mocks an already existing converted file that should not be + # removed or modified. + file_not_to_remove = self.create_dummy_file("artist/album/01 title.mp3") + original_mtime = os.path.getmtime(file_not_to_remove) + + self.run_convert("--yes", "--remove-missing") + + assert file_not_to_remove.exists() + assert os.path.getmtime(file_not_to_remove) == original_mtime