diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index aec0e80a9..09f30c109 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -389,17 +389,19 @@ def input_yn(prompt, require=False): return sel == u'y' -def input_select_objects(prompt, objs, rep): +def input_select_objects(prompt, objs, rep, prompt_all=None): """Prompt to user to choose all, none, or some of the given objects. Return the list of selected objects. `prompt` is the prompt string to use for each question (it should be - phrased as an imperative verb). `rep` is a function to call on each - object to print it out when confirming objects individually. + phrased as an imperative verb). If `prompt_all` is given, it is used + instead of `prompt` for the first (yes(/no/select) question. + `rep` is a function to call on each object to print it out when confirming + objects individually. """ choice = input_options( (u'y', u'n', u's'), False, - u'%s? (Yes/no/select)' % prompt) + u'%s? (Yes/no/select)' % (prompt_all or prompt)) print() # Blank line. if choice == u'y': # Yes. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index f34e5578f..49c4b4dc6 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1232,31 +1232,53 @@ def remove_items(lib, query, album, delete, force): """ # Get the matching items. items, albums = _do_query(lib, query, album) + objs = albums if album else items # Confirm file removal if not forcing removal. if not force: # Prepare confirmation with user. - print_() + album_str = u" in {} album{}".format( + len(albums), u's' if len(albums) > 1 else u'' + ) if album else "" + if delete: fmt = u'$path - $title' - prompt = u'Really DELETE %i file%s (y/n)?' % \ - (len(items), 's' if len(items) > 1 else '') + prompt = u'Really DELETE' + prompt_all = u'Really DELETE {} file{}{}'.format( + len(items), u's' if len(items) > 1 else u'', album_str + ) else: fmt = u'' - prompt = u'Really remove %i item%s from the library (y/n)?' % \ - (len(items), 's' if len(items) > 1 else '') + prompt = u'Really remove from the library?' + prompt_all = u'Really remove {} item{}{} from the library?'.format( + len(items), u's' if len(items) > 1 else u'', album_str + ) + + # Helpers for printing affected items + def fmt_track(t): + ui.print_(format(t, fmt)) + + def fmt_album(a): + ui.print_() + for i in a.items(): + fmt_track(i) + + fmt_obj = fmt_album if album else fmt_track # Show all the items. - for item in items: - ui.print_(format(item, fmt)) + for o in objs: + fmt_obj(o) # Confirm with user. - if not ui.input_yn(prompt, True): - return + objs = ui.input_select_objects(prompt, objs, fmt_obj, + prompt_all=prompt_all) + + if not objs: + return # Remove (and possibly delete) items. with lib.transaction(): - for obj in (albums if album else items): + for obj in objs: obj.remove(delete) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 724afc80a..2062193ab 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -230,10 +230,21 @@ remove Remove music from your library. This command uses the same :doc:`query ` syntax as the ``list`` command. -You'll be shown a list of the files that will be removed and asked to confirm. -By default, this just removes entries from the library database; it doesn't -touch the files on disk. To actually delete the files, use ``beet remove -d``. -If you do not want to be prompted to remove the files, use ``beet remove -f``. +By default, it just removes entries from the library database; it doesn't +touch the files on disk. To actually delete the files, use the ``-d`` flag. +When the ``-a`` flag is given, the command operates on albums instead of +individual tracks. + +When you run the ``remove`` command, it prints a list of all +affected items in the library and asks for your permission before removing +them. You can then choose to abort (type `n`), confirm (`y`), or interactively +choose some of the items (`s`). In the latter case, the command will prompt you +for every matching item or album and invite you to type `y` to remove the +item/album, `n` to keep it or `q` to exit and only remove the items/albums +selected up to this point. +This option lets you choose precisely which tracks/albums to remove without +spending too much time to carefully craft a query. +If you do not want to be prompted at all, use the ``-f`` option. .. _modify-cmd: diff --git a/test/test_ui.py b/test/test_ui.py index b1e7e8fad..f4ab8b16d 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -111,7 +111,7 @@ class ListTest(unittest.TestCase): self.assertNotIn(u'the album', stdout.getvalue()) -class RemoveTest(_common.TestCase): +class RemoveTest(_common.TestCase, TestHelper): def setUp(self): super(RemoveTest, self).setUp() @@ -122,8 +122,8 @@ class RemoveTest(_common.TestCase): # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) - item_path = os.path.join(_common.RSRC, b'full.mp3') - self.i = library.Item.from_path(item_path) + self.item_path = os.path.join(_common.RSRC, b'full.mp3') + self.i = library.Item.from_path(self.item_path) self.lib.add(self.i) self.i.move(operation=MoveOperation.COPY) @@ -153,6 +153,39 @@ class RemoveTest(_common.TestCase): self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) + def test_remove_items_select_with_delete(self): + i2 = library.Item.from_path(self.item_path) + self.lib.add(i2) + i2.move(operation=MoveOperation.COPY) + + for s in ('s', 'y', 'n'): + self.io.addinput(s) + commands.remove_items(self.lib, u'', False, True, False) + items = self.lib.items() + self.assertEqual(len(list(items)), 1) + # FIXME: is the order of the items as queried by the remove command + # really deterministic? + self.assertFalse(os.path.exists(syspath(self.i.path))) + self.assertTrue(os.path.exists(syspath(i2.path))) + + def test_remove_albums_select_with_delete(self): + a1 = self.add_album_fixture() + a2 = self.add_album_fixture() + path1 = a1.items()[0].path + path2 = a2.items()[0].path + items = self.lib.items() + self.assertEqual(len(list(items)), 3) + + for s in ('s', 'y', 'n'): + self.io.addinput(s) + commands.remove_items(self.lib, u'', True, True, False) + items = self.lib.items() + self.assertEqual(len(list(items)), 2) # incl. the item from setUp() + # FIXME: is the order of the items as queried by the remove command + # really deterministic? + self.assertFalse(os.path.exists(syspath(path1))) + self.assertTrue(os.path.exists(syspath(path2))) + class ModifyTest(unittest.TestCase, TestHelper):