Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions beetsplug/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -275,6 +283,7 @@ def auto_convert_keep(self, config, task):
hardlink,
link,
_,
_,
force,
) = self._get_opts_and_config(empty_opts)

Expand Down Expand Up @@ -600,6 +609,7 @@ def convert_func(self, lib, opts, args):
hardlink,
link,
playlist,
remove_missing,
force,
) = self._get_opts_and_config(opts)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -770,6 +790,7 @@ def _get_opts_and_config(self, opts):
hardlink,
link,
playlist,
remove_missing,
force,
)

Expand Down Expand Up @@ -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)}"')
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
9 changes: 9 additions & 0 deletions docs/plugins/convert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------

Expand Down Expand Up @@ -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):

Expand Down
76 changes: 76 additions & 0 deletions test/plugins/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,79 @@ 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.item = self.add_item_fixture(
title="title", artist="artist", album="album", format="flac"
)

self.convert_dest = self.temp_dir_path / "convert_dest"
self.convert_dest.mkdir(parents=True)

self.config["convert"] = {
"dest": str(self.convert_dest),
"format": "mp3",
}

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 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 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")

self.run_convert("--yes", "--remove-missing")

assert not file_to_remove.exists()

# This should hit the case where no files to remove are present
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
Loading