From a367b2764d60803acbede0959e85b751b65e4554 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 2 Aug 2011 13:59:33 -0700 Subject: [PATCH] first attempt at command-line modification command (#56) --- beets/library.py | 7 ++- beets/ui/commands.py | 132 +++++++++++++++++++++++++++++++++++++++---- test/test_ui.py | 79 ++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 14 deletions(-) diff --git a/beets/library.py b/beets/library.py index a3b408236..6cda6af79 100644 --- a/beets/library.py +++ b/beets/library.py @@ -726,8 +726,11 @@ class Library(BaseLibrary): art_filename='cover', item_fields=ITEM_FIELDS, album_fields=ALBUM_FIELDS): - self.path = bytestring_path(path) - self.directory = bytestring_path(directory) + if path == ':memory:': + self.path = path + else: + self.path = bytestring_path(normpath(path)) + self.directory = bytestring_path(normpath(directory)) if path_formats is None: path_formats = {'default': '$artist/$album/$track $title'} elif isinstance(path_formats, basestring): diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 080cade5f..1b0abbeaa 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -20,6 +20,7 @@ import logging import sys import os import time +import itertools from beets import ui from beets.ui import print_, decargs @@ -27,7 +28,8 @@ from beets import autotag import beets.autotag.art from beets import plugins from beets import importer -from beets.util import syspath, normpath +from beets.util import syspath, normpath, ancestry +from beets import library # Global logger. log = logging.getLogger('beets') @@ -36,6 +38,32 @@ log = logging.getLogger('beets') # objects that can be fed to a SubcommandsOptionParser. default_commands = [] +# Utility. +def _do_query(lib, query, album, also_items=True): + """For commands that operate on matched items, performs a query + and returns a list of matching items and a list of matching + albums. (The latter is only nonempty when album is True.) Raises + a UserError if no items match. also_items controls whether, when + fetching albums, the associated items should be fetched also. + """ + if album: + albums = list(lib.albums(query)) + items = [] + if also_items: + for al in albums: + items += al.items() + + else: + albums = [] + items = list(lib.items(query)) + + if album and not albums: + raise ui.UserError('No matching albums found.') + elif not album and not items: + raise ui.UserError('No matching items found.') + + return items, albums + # import: Autotagger and importer. @@ -634,17 +662,7 @@ def remove_items(lib, query, album, delete=False): remove whole albums. If delete, also remove files from disk. """ # Get the matching items. - if album: - albums = list(lib.albums(query)) - items = [] - for al in albums: - items += al.items() - else: - items = list(lib.items(query)) - - if not items: - print_('No matching items found.') - return + items, albums = _do_query(lib, query, album) # Show all the items. for item in items: @@ -740,3 +758,93 @@ version_cmd = ui.Subcommand('version', help='output version information') version_cmd.func = show_version default_commands.append(version_cmd) + + +# modify: Declaratively change metadata. + +def modify_items(lib, mods, query, write, move, album, color): + """Modifies matching items according to key=value assignments.""" + # Parse key=value specifications into a dictionary. + allowed_keys = library.ALBUM_KEYS if album else library.ITEM_KEYS_WRITABLE + fsets = {} + for mod in mods: + key, value = mod.split('=', 1) + if key not in allowed_keys: + raise ui.UserError('"%s" is not a valid field' % key) + fsets[key] = value + + # Get the items to modify. + items, albums = _do_query(lib, query, album, False) + objs = albums if album else items + + # Preview change. + print_('Modifying %i %ss.' % (len(objs), 'album' if album else 'item')) + for obj in objs: + # Identify the changed object. + if album: + print_(u'* %s - %s' % (obj.albumartist, obj.album)) + else: + print_(u'* %s - %s' % (obj.artist, obj.title)) + + # Show each change. + for field, value in fsets.iteritems(): + curval = getattr(obj, field) + if curval != value: + if color: + curval, value = ui.colordiff(curval, value) + print_(u' %s: %s -> %s' % (field, curval, value)) + + # Confirm. + extra = ' and write tags' if write else '' + if not ui.input_yn('Really modify%s (Y/n)?' % extra): + return + + # Apply changes to database. + for obj in objs: + for field, value in fsets.iteritems(): + setattr(obj, field, value) + + if move: + cur_path = obj.item_dir() if album else obj.path + if lib.directory in ancestry(cur_path): # In library? + log.debug('moving object %s' % cur_path) + if album: + obj.move() + else: + obj.move(lib) + + # When modifying items, we have to store them to the database. + if not album: + lib.store(obj) + lib.save() + + # Apply tags if requested. + if write: + if album: + items = itertools.chain(*(a.items() for a in albums)) + for item in items: + item.write() + +modify_cmd = ui.Subcommand('modify', + help='change metadata fields', aliases=('mod',)) +modify_cmd.parser.add_option('-M', '--nomove', action='store_false', + default=True, dest='move', help="don't move files in library") +modify_cmd.parser.add_option('-w', '--write', action='store_true', + default=None, help="write new metadata to files' tags (default)") +modify_cmd.parser.add_option('-W', '--nowrite', action='store_false', + dest='write', help="don't write metadata (opposite of -w)") +modify_cmd.parser.add_option('-a', '--album', action='store_true', + help='modify whole albums instead of tracks') +def modify_func(lib, config, opts, args): + args = decargs(args) + mods = [a for a in args if '=' in a] + query = [a for a in args if '=' not in a] + if not mods: + raise ui.UserError('no modifications specified') + write = opts.write if opts.write is not None else \ + ui.config_val(config, 'beets', 'import_write', + DEFAULT_IMPORT_WRITE, bool) + color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool) + modify_items(lib, mods, query, write, opts.move, opts.album, color) +modify_cmd.func = modify_func +default_commands.append(modify_cmd) diff --git a/test/test_ui.py b/test/test_ui.py index 99de272a6..d5f080580 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -119,6 +119,85 @@ class RemoveTest(unittest.TestCase): self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) +class ModifyTest(unittest.TestCase): + def setUp(self): + self.io = _common.DummyIO() + self.io.install() + + self.libdir = os.path.join(_common.RSRC, 'testlibdir') + os.mkdir(self.libdir) + + # Copy a file into the library. + self.lib = library.Library(':memory:', self.libdir) + self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) + self.lib.add(self.i, True) + self.album = self.lib.add_album([self.i]) + + def tearDown(self): + self.io.restore() + shutil.rmtree(self.libdir) + + def _modify(self, mods, query=(), write=False, move=False, album=False): + self.io.addinput('y') + commands.modify_items(self.lib, mods, query, + write, move, album, True) + + def test_modify_item_dbdata(self): + self._modify(["title=newTitle"]) + item = self.lib.items().next() + self.assertEqual(item.title, 'newTitle') + + def test_modify_album_dbdata(self): + self._modify(["album=newAlbum"], album=True) + album = self.lib.albums()[0] + self.assertEqual(album.album, 'newAlbum') + + def test_modify_item_tag_unmodified(self): + self._modify(["title=newTitle"], write=False) + item = self.lib.items().next() + item.read() + self.assertEqual(item.title, 'full') + + def test_modify_album_tag_unmodified(self): + self._modify(["album=newAlbum"], write=False, album=True) + item = self.lib.items().next() + item.read() + self.assertEqual(item.album, 'the album') + + def test_modify_item_tag(self): + self._modify(["title=newTitle"], write=True) + item = self.lib.items().next() + item.read() + self.assertEqual(item.title, 'newTitle') + + def test_modify_album_tag(self): + self._modify(["album=newAlbum"], write=True, album=True) + item = self.lib.items().next() + item.read() + self.assertEqual(item.album, 'newAlbum') + + def test_item_move(self): + self._modify(["title=newTitle"], move=True) + item = self.lib.items().next() + self.assertTrue('newTitle' in item.path) + + def test_album_move(self): + self._modify(["album=newAlbum"], move=True, album=True) + item = self.lib.items().next() + item.read() + self.assertTrue('newAlbum' in item.path) + + def test_item_not_move(self): + self._modify(["title=newTitle"], move=False) + item = self.lib.items().next() + self.assertFalse('newTitle' in item.path) + + def test_album_not_move(self): + self._modify(["album=newAlbum"], move=False, album=True) + item = self.lib.items().next() + item.read() + self.assertFalse('newAlbum' in item.path) + class PrintTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO()