diff --git a/NEWS b/NEWS index df5852b90..65586e55b 100644 --- a/NEWS +++ b/NEWS @@ -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 diff --git a/beets/library.py b/beets/library.py index 44490aba6..7986cd0e4 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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. diff --git a/beets/mediafile.py b/beets/mediafile.py index b31b638a1..12281f1f5 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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')] ) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index f2a3acfd6..ee859da73 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 041981106..239294a74 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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 diff --git a/test/test_files.py b/test/test_files.py index b534721aa..b5a7b42c6 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -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) diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 97cf8bc20..5fa7cdd62 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -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__) diff --git a/test/test_ui.py b/test/test_ui.py index 99de272a6..721f5dd9f 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, 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()