diff --git a/NEWS b/NEWS index 65586e55b..d34416507 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,11 @@ * A new "beet modify" command enables manual, command-line-based modification of music metadata. Pass it a query along with field=value that specify the changes you want to make. +* A new "beet update" command updates the database to reflect + changes in the on-disk metadata. You can now use an external + program to edit tags on files, remove files and directories, etc., + and then run "beet update" to make sure your beets library is in + sync. This will also rename files to reflect their new metadata. * Handle exceptions thrown when running Mutagen. * Fix a missing __future__ import in embedart on Python 2.5. * Fix ID3 and MPEG-4 tag names for the album-artist field. diff --git a/beets/library.py b/beets/library.py index 7986cd0e4..0ff5d964e 100644 --- a/beets/library.py +++ b/beets/library.py @@ -19,16 +19,13 @@ import shutil import sys from string import Template import logging -from beets.mediafile import MediaFile, UnreadableFileError +from beets.mediafile import MediaFile from beets import plugins from beets import util from beets.util import bytestring_path, syspath, normpath MAX_FILENAME_LENGTH = 200 -ITEM_MODIFIED = 1 -ITEM_DELETED = 2 - # Fields in the "items" database table; all the metadata available for # items in the library. These are used directly in SQL; they are # vulnerable to injection if accessible to the user. @@ -963,58 +960,7 @@ class Library(BaseLibrary): if delete: util.soft_remove(item.path) util.prune_dirs(os.path.dirname(item.path), self.directory) - - def update(self, item, move=True): - """Reads the item's metadata from file, and updates the library. If - move, then the files will be moved to reflect the changes. - """ - modified = False - deleted = False - - old_album = self.get_album(item) - - try: - item.read() - except UnreadableFileError: - # File no longer exists - deleted = True - self.remove(item, False) - else: - # Has the metadata been modified? - for key in ITEM_KEYS: - if (key != 'id') and item.dirty[key]: - modified = True - break - - if modified: - new_album = self.get_album_by_item_properties(item) - if new_album is None: - # No existing album matching the new metadata, so we create one - new_album = self.add_album((item,)) - if move and old_album and old_album.artpath: - new_album.set_art(old_album.artpath) - item.album_id = new_album.id - - old_path = item.path - if move: - item.move(self) - - self.store(item) - - # Delete old album if it's empty - if move and old_album: - item_iter = old_album.items() - try: - item_iter.next() - except StopIteration: - # Album is empty. - old_album.remove(True, False) - util.prune_dirs(os.path.dirname(old_path), self.directory) - - if deleted: - return ITEM_DELETED - if modified: - return ITEM_MODIFIED + # Querying. @@ -1123,30 +1069,6 @@ class Library(BaseLibrary): self.store(item) return album - - def get_album_by_item_properties(self, item): - """Given an item, return an Album object that matches all the item's - fields listed in ALBUM_KEYS_ITEM. If no such album exists, returns - None.""" - item_values = dict( - (key, getattr(item, key)) for key in ALBUM_KEYS_ITEM) - - queries = [] - for key in ALBUM_KEYS_ITEM: - queries.append(MatchQuery(key, item_values[key])) - super_query = AndQuery(queries) - where, subvals = super_query.clause() - - sql = "SELECT * FROM albums " + \ - "WHERE " + where + \ - " ORDER BY albumartist, album" - c = self.conn.execute(sql, subvals) - try: - record = c.fetchone() - finally: - c.close() - if record: - return Album(self, dict(record)) class Album(BaseAlbum): """Provides access to information about albums stored in a @@ -1231,10 +1153,6 @@ class Album(BaseAlbum): 'DELETE FROM albums WHERE id=?', (self.id,) ) - - def update(self): - for item in self.items(): - self._library.update(item) def move(self, copy=False): """Moves (or copies) all items to their destination. Any diff --git a/beets/ui/commands.py b/beets/ui/commands.py index ee859da73..1814e9acd 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -20,7 +20,6 @@ import logging import sys import os import time -import copy import itertools from beets import ui @@ -29,7 +28,6 @@ from beets import autotag import beets.autotag.art from beets import plugins from beets import importer -from beets.library import ITEM_MODIFIED, ITEM_DELETED from beets.util import syspath, normpath, ancestry from beets import library @@ -41,6 +39,7 @@ log = logging.getLogger('beets') 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 @@ -66,6 +65,13 @@ def _do_query(lib, query, album, also_items=True): return items, albums +def _showdiff(field, oldval, newval, color): + """Prints out a human-readable field difference line.""" + if newval != oldval: + if color: + oldval, newval = ui.colordiff(oldval, newval) + print_(u' %s: %s -> %s' % (field, oldval, newval)) + # import: Autotagger and importer. @@ -184,21 +190,6 @@ def show_item_change(item, info, dist, color): print_('(Similarity: %s)' % dist_string(dist, color)) -def show_item_update(old_item, new_item, color=True): - """Print out the changes detected on an existing item.""" - old_artist, new_artist = old_item.artist, new_item.artist - old_title, new_title = old_item.title, new_item.title - - if old_artist != new_artist or old_title != new_title: - if color: - old_artist, new_artist = ui.colordiff(old_artist, new_artist) - old_title, new_title = ui.colordiff(old_title, new_title) - - print_("Updated: %s - %s -> %s - %s" % (old_artist, old_title, new_artist, new_title)) - - else: - print_("Updated: %s - %s (secondary tags)" % (old_artist, old_title)) - def should_resume(config, path): return ui.input_yn("Import of the directory:\n%s" "\nwas interrupted. Resume (Y/n)?" % path) @@ -672,52 +663,76 @@ list_cmd.func = list_func default_commands.append(list_cmd) -# update: Query and update library contents. +# update: Update library contents according to on-disk tags. -def update_items(lib, query, album, path): - """Print out items in lib matching query. If album, then search for - albums instead of single items. If path, print the matched objects' - paths instead of human-readable information about them. +def update_items(lib, query, album, move, color): + """For all the items matched by the query, update the library to + reflect the item's embedded tags. """ - # 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)) + items, _ = _do_query(lib, query, album) - if not items: - print_('No matching items found.') - return + # Walk through the items and pick up their changes. + affected_albums = set() + for item in items: + # Item deleted? + if not os.path.exists(syspath(item.path)): + print_(u'X %s - %s' % (item.artist, item.title)) + lib.remove(item, True) + affected_albums.add(item.album_id) + continue - # Show all the items. - #for item in items: - # print_(item.artist + ' - ' + item.album + ' - ' + item.title) + # Read new data. + old_data = dict(item.record) + item.read() - # Remove (and possibly delete) items. - if album: - for al in albums: - al.update() - else: - for item in items: - old_item = copy.deepcopy(item) - ret = lib.update(item) - if ret == ITEM_MODIFIED: - show_item_update(old_item, item) - elif ret == ITEM_DELETED: - print_("Deleted: %s - %s" % (item.artist, item.title)) + # Get and save metadata changes. + changes = {} + for key in library.ITEM_KEYS_META: + if item.dirty[key]: + changes[key] = old_data[key], getattr(item, key) + if changes: + # Something changed. + print_(u'* %s - %s' % (item.artist, item.title)) + for key, (oldval, newval) in changes.iteritems(): + _showdiff(key, oldval, newval, color) + + # Move the item if it's in the library. + if move and lib.directory in ancestry(item.path): + item.move(lib) + + lib.store(item) + affected_albums.add(item.album_id) + + # Modify affected albums to reflect changes in their items. + for album_id in affected_albums: + if album_id is None: # Singletons. + continue + album = lib.get_album(album_id) + if not album: # Empty albums have already been removed. + log.debug('emptied album %i' % album_id) + continue + al_items = list(album.items()) + + # Update album structure to reflect an item in it. + for key in library.ALBUM_KEYS_ITEM: + setattr(album, key, getattr(al_items[0], key)) + + # Move album art (and any inconsistent items). + if move and lib.directory in ancestry(al_items[0].path): + log.debug('moving album %i' % album_id) + album.move() lib.save() -update_cmd = ui.Subcommand('update', help='update the library', aliases=('upd','up',)) +update_cmd = ui.Subcommand('update', + help='update the library', aliases=('upd','up',)) update_cmd.parser.add_option('-a', '--album', action='store_true', help='show matching albums instead of tracks') -update_cmd.parser.add_option('-p', '--path', action='store_true', - help='print paths for matched items or albums') +update_cmd.parser.add_option('-M', '--nomove', action='store_false', + default=True, dest='move', help="don't move files in library") def update_func(lib, config, opts, args): - update_items(lib, decargs(args), opts.album, opts.path) + color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool) + update_items(lib, decargs(args), opts.album, opts.move, color) update_cmd.func = update_func default_commands.append(update_cmd) @@ -856,10 +871,7 @@ def modify_items(lib, mods, query, write, move, album, color, confirm): # 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)) + _showdiff(field, curval, value, color) # Confirm. if confirm: diff --git a/test/test_ui.py b/test/test_ui.py index 721f5dd9f..ec99c8c7a 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -26,6 +26,7 @@ from beets import ui from beets.ui import commands from beets import autotag from beets import importer +from beets.mediafile import MediaFile class ListTest(unittest.TestCase): def setUp(self): @@ -198,6 +199,94 @@ class ModifyTest(unittest.TestCase): item.read() self.assertFalse('newAlbum' in item.path) +class UpdateTest(unittest.TestCase, _common.ExtraAsserts): + 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]) + + # Album art. + artfile = os.path.join(_common.RSRC, 'testart.jpg') + _common.touch(artfile) + self.album.set_art(artfile) + os.remove(artfile) + + def tearDown(self): + self.io.restore() + shutil.rmtree(self.libdir) + + def _update(self, query=(), album=False, move=False): + self.io.addinput('y') + commands.update_items(self.lib, query, album, move, True) + + def test_delete_removes_item(self): + self.assertTrue(list(self.lib.items())) + os.remove(self.i.path) + self._update() + self.assertFalse(list(self.lib.items())) + + def test_delete_removes_album(self): + self.assertTrue(self.lib.albums()) + os.remove(self.i.path) + self._update() + self.assertFalse(self.lib.albums()) + + def test_delete_removes_album_art(self): + artpath = self.album.artpath + self.assertExists(artpath) + os.remove(self.i.path) + self._update() + self.assertNotExists(artpath) + + def test_modified_metadata_detected(self): + mf = MediaFile(self.i.path) + mf.title = 'differentTitle' + mf.save() + self._update() + item = self.lib.items().next() + self.assertEqual(item.title, 'differentTitle') + + def test_modified_metadata_moved(self): + mf = MediaFile(self.i.path) + mf.title = 'differentTitle' + mf.save() + self._update(move=True) + item = self.lib.items().next() + self.assertTrue('differentTitle' in item.path) + + def test_modified_metadata_not_moved(self): + mf = MediaFile(self.i.path) + mf.title = 'differentTitle' + mf.save() + self._update(move=False) + item = self.lib.items().next() + self.assertTrue('differentTitle' not in item.path) + + def test_modified_album_metadata_moved(self): + mf = MediaFile(self.i.path) + mf.album = 'differentAlbum' + mf.save() + self._update(move=True) + item = self.lib.items().next() + self.assertTrue('differentAlbum' in item.path) + + def test_modified_album_metadata_art_moved(self): + artpath = self.album.artpath + mf = MediaFile(self.i.path) + mf.album = 'differentAlbum' + mf.save() + self._update(move=True) + album = self.lib.albums()[0] + self.assertNotEqual(artpath, album.artpath) + class PrintTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO()