mirror of
https://github.com/beetbox/beets.git
synced 2026-01-01 05:23:05 +01:00
merge in wlof's "update" command
This commit is contained in:
commit
fdf697dc90
8 changed files with 283 additions and 27 deletions
5
NEWS
5
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue