consolidate update command, removing album-munging logic

This commit is contained in:
Adrian Sampson 2011-08-02 23:37:55 -07:00
parent fdf697dc90
commit e84c3e7abd
4 changed files with 164 additions and 140 deletions

5
NEWS
View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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()