merge in wlof's "update" command

This commit is contained in:
Adrian Sampson 2011-08-02 20:43:21 -07:00
commit fdf697dc90
8 changed files with 283 additions and 27 deletions

5
NEWS
View file

@ -1,6 +1,11 @@
1.0b10
------
* 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.
* 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.
* Fix Unicode encoding of album artist, album type, and label.
1.0b9

View file

@ -250,8 +250,13 @@ class Item(object):
shutil.move(syspath(self.path), syspath(dest))
# Either copying or moving succeeded, so update the stored path.
old_path = self.path
self.path = dest
# Prune vacated directory.
if not copy:
util.prune_dirs(os.path.dirname(old_path), library.directory)
# Library queries.
@ -729,8 +734,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):
@ -1248,6 +1256,9 @@ class Album(BaseAlbum):
else:
shutil.move(syspath(old_art), syspath(new_art))
self.artpath = new_art
if not copy: # Prune old path.
util.prune_dirs(os.path.dirname(old_art),
self._library.directory)
# Store new item paths. We do this at the end to avoid
# locking the database for too long while files are copied.

View file

@ -39,11 +39,17 @@ import re
import base64
import imghdr
import os
import logging
import traceback
from beets.util.enumeration import enum
__all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile']
# Logger.
log = logging.getLogger('beets')
# Exceptions.
# Raised for any file MediaFile can't read.
@ -382,6 +388,11 @@ class MediaField(object):
if style.packing:
out = Packed(out, style.packing)[style.pack_pos]
# MPEG-4 freeform frames are (should be?) encoded as UTF-8.
if obj.type == 'mp4' and style.key.startswith('----:') and \
isinstance(out, str):
out = out.decode('utf8')
return _safe_cast(self.out_type, out)
@ -410,8 +421,8 @@ class MediaField(object):
out = u''
# We trust that packed values are handled above.
# convert to correct storage type (irrelevant for
# packed values)
# Convert to correct storage type (irrelevant for
# packed values).
if style.as_type == unicode:
if out is None:
out = u''
@ -429,7 +440,13 @@ class MediaField(object):
elif style.as_type in (bool, str):
out = style.as_type(out)
# store the data
# MPEG-4 "freeform" (----) frames must be encoded as UTF-8
# byte strings.
if obj.type == 'mp4' and style.key.startswith('----:') and \
isinstance(out, unicode):
out = out.encode('utf8')
# Store the data.
self._storedata(obj, out, style)
class CompositeDateField(object):
@ -619,9 +636,14 @@ class MediaFile(object):
try:
self.mgfile = mutagen.File(path)
except unreadable_exc:
log.warn('header parsing failed')
raise UnreadableFileError('Mutagen could not read file')
except IOError:
raise UnreadableFileError('could not read file')
except:
# Hide bugs in Mutagen.
log.error('uncaught Mutagen exception:\n' + traceback.format_exc())
raise UnreadableFileError('Mutagen raised an exception')
if self.mgfile is None: # Mutagen couldn't guess the type
raise FileTypeError('file type unsupported by Mutagen')
@ -788,9 +810,8 @@ class MediaFile(object):
etc = StorageStyle('compilation')
)
albumartist = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'Album Artist'),
mp4 = StorageStyle(
'----:com.apple.iTunes:Album Artist'),
mp3 = StorageStyle('TPE2'),
mp4 = StorageStyle('aART'),
etc = [StorageStyle('album artist'),
StorageStyle('albumartist')]
)

View file

@ -21,6 +21,7 @@ import sys
import os
import time
import copy
import itertools
from beets import ui
from beets.ui import print_, decargs
@ -28,8 +29,9 @@ from beets import autotag
import beets.autotag.art
from beets import plugins
from beets import importer
from beets.util import syspath, normpath
from beets.library import ITEM_MODIFIED, ITEM_DELETED
from beets.util import syspath, normpath, ancestry
from beets import library
# Global logger.
log = logging.getLogger('beets')
@ -38,6 +40,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.
@ -701,17 +729,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:
@ -807,3 +825,97 @@ 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, confirm):
"""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.
if 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')
modify_cmd.parser.add_option('-y', '--yes', action='store_true',
help='skip confirmation')
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,
not opts.yes)
modify_cmd.func = modify_func
default_commands.append(modify_cmd)

View file

@ -180,7 +180,7 @@ CHAR_REPLACE = [
(re.compile(r'[\\/\?]|^\.'), '_'),
(re.compile(r':'), '-'),
]
CHAR_REPLACE_WINDOWS = re.compile('["\*<>\|]|^\.|\.$| +$'), '_'
CHAR_REPLACE_WINDOWS = re.compile(r'["\*<>\|]|^\.|\.$| +$'), '_'
def sanitize_path(path, pathmod=None):
"""Takes a path and makes sure that it is legal. Returns a new path.
Only works with fragments; won't work reliably on Windows when a

View file

@ -26,7 +26,7 @@ from _common import item, touch
import beets.library
from beets import util
class MoveTest(unittest.TestCase):
class MoveTest(unittest.TestCase, _common.ExtraAsserts):
def setUp(self):
# make a temporary file
self.path = join(_common.RSRC, 'temp.mp3')
@ -54,19 +54,29 @@ class MoveTest(unittest.TestCase):
def test_move_arrives(self):
self.i.move(self.lib)
self.assertTrue(os.path.exists(self.dest))
self.assertExists(self.dest)
def test_move_departs(self):
self.i.move(self.lib)
self.assertTrue(not os.path.exists(self.path))
self.assertNotExists(self.path)
def test_move_in_lib_prunes_empty_dir(self):
self.i.move(self.lib)
old_path = self.i.path
self.assertExists(old_path)
self.i.artist = 'newArtist'
self.i.move(self.lib)
self.assertNotExists(old_path)
self.assertNotExists(os.path.dirname(old_path))
def test_copy_arrives(self):
self.i.move(self.lib, copy=True)
self.assertTrue(os.path.exists(self.dest))
self.assertExists(self.dest)
def test_copy_does_not_depart(self):
self.i.move(self.lib, copy=True)
self.assertTrue(os.path.exists(self.path))
self.assertExists(self.path)
def test_move_changes_path(self):
self.i.move(self.lib)
@ -257,7 +267,7 @@ class RemoveTest(unittest.TestCase):
if os.path.exists(self.libdir):
shutil.rmtree(self.libdir)
def test_removing_last_item_removes_empty_dir(self):
def test_removing_last_item_prunes_empty_dir(self):
parent = os.path.dirname(self.i.path)
self.assertTrue(os.path.exists(parent))
self.lib.remove(self.i, True)

View file

@ -17,6 +17,7 @@
import unittest
import os
import shutil
import _common
import beets.mediafile
@ -149,6 +150,23 @@ class SideEffectsTest(unittest.TestCase):
new_mtime = os.stat(self.empty).st_mtime
self.assertEqual(old_mtime, new_mtime)
class EncodingTest(unittest.TestCase):
def setUp(self):
src = os.path.join(_common.RSRC, 'full.m4a')
self.path = os.path.join(_common.RSRC, 'test.m4a')
shutil.copy(src, self.path)
self.mf = beets.mediafile.MediaFile(self.path)
def tearDown(self):
os.remove(self.path)
def test_unicode_label_in_m4a(self):
self.mf.label = u'foo\xe8bar'
self.mf.save()
new_mf = beets.mediafile.MediaFile(self.path)
self.assertEqual(new_mf.label, u'foo\xe8bar')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

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