diff --git a/README.rst b/README.rst index 9c336e737..8780e95a8 100644 --- a/README.rst +++ b/README.rst @@ -31,11 +31,11 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea: If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. -.. _plugins: http://readthedocs.org/docs/beets/-/plugins/ +.. _plugins: http://beets.readthedocs.org/en/latest/plugins/ .. _MPD: http://mpd.wikia.com/ .. _MusicBrainz music collection: http://musicbrainz.org/show/collection/ .. _writing your own plugin: - http://readthedocs.org/docs/beets/-/plugins/#writing-plugins + http://beets.readthedocs.org/en/latest/plugins/#writing-plugins .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html @@ -50,7 +50,7 @@ cutting edge, type ``pip install beets==dev`` for the `latest source`_.) Check out the `Getting Started`_ guide to learn more about installing and using beets. .. _its Web site: http://beets.radbox.org/ -.. _Getting Started: http://readthedocs.org/docs/beets/-/guides/main.html +.. _Getting Started: http://beets.readthedocs.org/en/latest/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ .. _latest source: https://github.com/sampsyo/beets/tarball/master#egg=beets-dev diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 74568b8f5..73c228d8c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -19,7 +19,7 @@ import logging import re from beets import library, mediafile, config -from beets.util import sorted_walk, ancestry +from beets.util import sorted_walk, ancestry, displayable_path # Parts of external interface. from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch @@ -57,7 +57,9 @@ def albums_in_dir(path, ignore=()): except mediafile.FileTypeError: pass except mediafile.UnreadableFileError: - log.warn('unreadable file: ' + filename) + log.warn(u'unreadable file: {0}'.format( + displayable_path(filename)) + ) else: items.append(i) diff --git a/beets/library.py b/beets/library.py index b5e60d657..d767331fb 100644 --- a/beets/library.py +++ b/beets/library.py @@ -29,7 +29,8 @@ from unidecode import unidecode from beets.mediafile import MediaFile from beets import plugins from beets import util -from beets.util import bytestring_path, syspath, normpath, samefile +from beets.util import bytestring_path, syspath, normpath, samefile,\ + displayable_path from beets.util.functemplate import Template MAX_FILENAME_LENGTH = 200 @@ -88,6 +89,10 @@ ITEM_FIELDS = [ ('albumdisambig', 'text', True, True), ('disctitle', 'text', True, True), ('encoder', 'text', True, True), + ('rg_track_gain', 'real', True, True), + ('rg_track_peak', 'real', True, True), + ('rg_album_gain', 'real', True, True), + ('rg_album_peak', 'real', True, True), ('length', 'real', False, True), ('bitrate', 'int', False, True), @@ -132,6 +137,8 @@ ALBUM_FIELDS = [ ('albumstatus', 'text', True), ('media', 'text', True), ('albumdisambig', 'text', True), + ('rg_album_gain', 'real', True), + ('rg_album_peak', 'real', True), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] @@ -266,16 +273,23 @@ class Item(object): read_path = self.path else: read_path = normpath(read_path) - f = MediaFile(syspath(read_path)) + try: + f = MediaFile(syspath(read_path)) + except Exception: + log.error(u'failed reading file: {0}'.format( + displayable_path(read_path)) + ) + raise for key in ITEM_KEYS_META: setattr(self, key, getattr(f, key)) - self.path = read_path # Database's mtime should now reflect the on-disk value. if read_path == self.path: self.mtime = self.current_mtime() + self.path = read_path + def write(self): """Writes the item's metadata to the associated file. """ @@ -351,7 +365,7 @@ class Item(object): # Additional fields in non-sanitized case. if not sanitize: - mapping['path'] = self.path + mapping['path'] = displayable_path(self.path) # Use the album artist if the track artist is not set and # vice-versa. @@ -596,7 +610,7 @@ class CollectionQuery(Query): # Unrecognized field. else: - log.warn('no such field in query: {0}'.format(key)) + log.warn(u'no such field in query: {0}'.format(key)) if not subqueries: # No terms in query. subqueries = [TrueQuery()] @@ -1585,6 +1599,9 @@ class Album(BaseAlbum): for key in ALBUM_KEYS: mapping[key] = getattr(self, key) + mapping['artpath'] = displayable_path(mapping['artpath']) + mapping['path'] = displayable_path(self.item_dir()) + # Get template functions. funcs = DefaultTemplateFunctions().functions() funcs.update(plugins.template_funcs()) diff --git a/beets/mediafile.py b/beets/mediafile.py index 48b212b83..b5e0acce2 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -255,7 +255,7 @@ class Packed(object): field_lengths = [4, 2, 2] # YYYY-MM-DD elems = [] for i, item in enumerate(new_items): - elems.append( ('%0' + str(field_lengths[i]) + 'i') % item ) + elems.append('{0:0{1}}'.format(int(item), field_lengths[i])) self.items = '-'.join(elems) elif self.packstyle == packing.TUPLE: self.items = new_items diff --git a/beets/plugins.py b/beets/plugins.py index 0752422e4..ca93b9d0a 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -22,7 +22,6 @@ from collections import defaultdict from beets import mediafile PLUGIN_NAMESPACE = 'beetsplug' -DEFAULT_PLUGINS = [] # Plugins using the Last.fm API can share the same API key. LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43' @@ -151,24 +150,29 @@ class BeetsPlugin(object): return func return helper +_classes = set() def load_plugins(names=()): """Imports the modules for a sequence of plugin names. Each name must be the name of a Python module under the "beetsplug" namespace package in sys.path; the module indicated should contain the - BeetsPlugin subclasses desired. A default set of plugins is also - loaded. + BeetsPlugin subclasses desired. """ - for name in itertools.chain(names, DEFAULT_PLUGINS): + for name in names: modname = '%s.%s' % (PLUGIN_NAMESPACE, name) try: try: - __import__(modname, None, None) + namespace = __import__(modname, None, None) except ImportError as exc: # Again, this is hacky: if exc.args[0].endswith(' ' + name): log.warn('** plugin %s not found' % name) else: raise + else: + for obj in getattr(namespace, name).__dict__.values(): + if isinstance(obj, type) and issubclass(obj, BeetsPlugin): + _classes.add(obj) + except: log.warn('** error loading plugin %s' % name) log.warn(traceback.format_exc()) @@ -181,7 +185,7 @@ def find_plugins(): """ load_plugins() plugins = [] - for cls in BeetsPlugin.__subclasses__(): + for cls in _classes: # Only instantiate each plugin class once. if cls not in _instances: _instances[cls] = cls() diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 17461994e..b2ee53d5b 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -38,6 +38,7 @@ from beets.util import confit # On Windows platforms, use colorama to support "ANSI" terminal colors. + if sys.platform == 'win32': try: import colorama @@ -48,6 +49,7 @@ if sys.platform == 'win32': # Constants. + PF_KEY_QUERIES = { 'comp': 'comp:true', 'singleton': 'singleton:true', @@ -114,7 +116,7 @@ def input_(prompt=None): except EOFError: raise UserError('stdin stream ended while input required') - return resp.decode(sys.stdin.encoding, 'ignore') + return resp.decode(sys.stdin.encoding or 'utf8', 'ignore') def input_options(options, require=False, prompt=None, fallback_prompt=None, numrange=None, default=None, max_width=72): @@ -407,6 +409,33 @@ def get_replacements(): # FIXME handle regex compilation errors return [(re.compile(k), v) for (k, v) in pairs] +def _pick_format(album=False, fmt=None): + """Pick a format string for printing Album or Item objects, + falling back to config options and defaults. + """ + if fmt: + return fmt + if album: + return config['list_format_album'].get(unicode) + else: + return config['list_format_item'].get(unicode) + +def print_obj(obj, lib, fmt=None): + """Print an Album or Item object. If `fmt` is specified, use that + format string. Otherwise, use the configured template. + """ + album = isinstance(obj, library.Album) + if not fmt: + fmt = _pick_format(album=album) + if isinstance(fmt, Template): + template = fmt + else: + template = Template(fmt) + if album: + print_(obj.evaluate_template(template)) + else: + print_(obj.evaluate_template(template, lib=lib)) + # Subcommand parsing infrastructure. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index e69db38d7..c67b701b9 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -41,7 +41,8 @@ log = logging.getLogger('beets') # objects that can be fed to a SubcommandsOptionParser. default_commands = [] -# Utility. + +# Utilities. def _do_query(lib, query, album, also_items=True): """For commands that operate on matched items, performs a query @@ -86,6 +87,7 @@ def _showdiff(field, oldval, newval): # fields: Shows a list of available fields for queries and format strings. + fields_cmd = ui.Subcommand('fields', help='show fields available for queries and format strings') def fields_func(lib, config, opts, args): @@ -465,7 +467,7 @@ def manual_id(singleton): # Find the first thing that looks like a UUID/MBID. match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', entry) if match: - return match.group() + return match.group() else: log.error('Invalid MBID.') return None @@ -751,25 +753,17 @@ default_commands.append(import_cmd) # list: Query and show library contents. -def list_items(lib, query, album, path, fmt): +def list_items(lib, query, album, fmt): """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. + albums instead of single items. """ - template = Template(fmt) - + tmpl = Template(fmt) if fmt else Template(ui._pick_format(config, album)) if album: for album in lib.albums(query): - if path: - print_(album.item_dir()) - elif fmt is not None: - print_(album.evaluate_template(template)) + ui.print_obj(album, lib, tmpl) else: for item in lib.items(query): - if path: - print_(item.path) - elif fmt is not None: - print_(item.evaluate_template(template, lib)) + ui.print_obj(item, lib, tmpl) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd.parser.add_option('-a', '--album', action='store_true', @@ -779,14 +773,11 @@ list_cmd.parser.add_option('-p', '--path', action='store_true', list_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) def list_func(lib, config, opts, args): - fmt = opts.format - if not fmt: - # If no format is specified, fall back to a default. - if opts.album: - fmt = config['list_format_album'].get(unicode) - else: - fmt = config['list_format_item'].get(unicode) - list_items(lib, decargs(args), opts.album, opts.path, fmt) + if opts.path: + fmt = '$path' + else: + fmt = opts.format + list_items(lib, decargs(args), opts.album, fmt) list_cmd.func = list_func default_commands.append(list_cmd) @@ -805,7 +796,7 @@ def update_items(lib, query, album, move, pretend): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - print_(u'X %s - %s' % (item.artist, item.title)) + ui.print_obj(item, lib) if not pretend: lib.remove(item, True) affected_albums.add(item.album_id) @@ -837,7 +828,7 @@ def update_items(lib, query, album, move, pretend): changes[key] = old_data[key], getattr(item, key) if changes: # Something changed. - print_(u'* %s - %s' % (item.artist, item.title)) + ui.print_obj(item, lib) for key, (oldval, newval) in changes.iteritems(): _showdiff(key, oldval, newval) @@ -889,6 +880,8 @@ update_cmd.parser.add_option('-M', '--nomove', action='store_false', default=True, dest='move', help="don't move files in library") update_cmd.parser.add_option('-p', '--pretend', action='store_true', help="show all changes but do nothing") +update_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format', default=None) def update_func(lib, config, opts, args): update_items(lib, decargs(args), opts.album, opts.move, opts.pretend) update_cmd.func = update_func @@ -897,7 +890,7 @@ default_commands.append(update_cmd) # remove: Remove items from library, delete files. -def remove_items(lib, query, album, delete=False): +def remove_items(lib, query, album, delete): """Remove items matching query from lib. If album, then match and remove whole albums. If delete, also remove files from disk. """ @@ -906,7 +899,7 @@ def remove_items(lib, query, album, delete=False): # Show all the items. for item in items: - print_(item.artist + ' - ' + item.album + ' - ' + item.title) + ui.print_obj(item, lib) # Confirm with user. print_() @@ -941,7 +934,7 @@ default_commands.append(remove_cmd) # stats: Show library/query statistics. -def show_stats(lib, query): +def show_stats(lib, query, exact): """Shows some statistics about the matched items.""" items = lib.items(query) @@ -952,30 +945,32 @@ def show_stats(lib, query): albums = set() for item in items: - #fixme This is approximate, so people might complain that - # this total size doesn't match "du -sh". Could fix this - # by putting total file size in the database. - total_size += int(item.length * item.bitrate / 8) + if exact: + total_size += os.path.getsize(item.path) + else: + total_size += int(item.length * item.bitrate / 8) total_time += item.length total_items += 1 artists.add(item.artist) albums.add(item.album) - print_("""Tracks: %i -Total time: %s -Total size: %s -Artists: %i -Albums: %i""" % ( - total_items, - ui.human_seconds(total_time), - ui.human_bytes(total_size), - len(artists), len(albums) - )) + size_str = '' + ui.human_bytes(total_size) + if exact: + size_str += ' ({0} bytes)'.format(total_size) + + print_("""Tracks: {0} +Total time: {1} ({2:.2f} seconds) +Total size: {3} +Artists: {4} +Albums: {5}""".format(total_items, ui.human_seconds(total_time), total_time, + size_str, len(artists), len(albums))) stats_cmd = ui.Subcommand('stats', help='show statistics about the library or a query') +stats_cmd.parser.add_option('-e', '--exact', action='store_true', + help='get exact file sizes') def stats_func(lib, config, opts, args): - show_stats(lib, decargs(args)) + show_stats(lib, decargs(args), opts.exact) stats_cmd.func = stats_func default_commands.append(stats_cmd) @@ -1020,10 +1015,7 @@ def modify_items(lib, mods, query, write, move, album, confirm): 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)) + ui.print_obj(obj, lib) # Show each change. for field, value in fsets.iteritems(): @@ -1074,6 +1066,8 @@ 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') +modify_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format', default=None) def modify_func(lib, config, opts, args): args = decargs(args) mods = [a for a in args if '=' in a] diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e311692a9..6074c557b 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -24,6 +24,7 @@ from collections import defaultdict import traceback MAX_FILENAME_LENGTH = 200 +WINDOWS_MAGIC_PREFIX = u'\\\\?\\' class HumanReadableException(Exception): """An Exception that can include a human-readable error message to @@ -108,13 +109,18 @@ def normpath(path): """Provide the canonical form of the path suitable for storing in the database. """ - return os.path.normpath(os.path.abspath(os.path.expanduser(path))) + path = syspath(path) + path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) + return bytestring_path(path) def ancestry(path, pathmod=None): """Return a list consisting of path's parent directory, its grandparent, and so on. For instance: + >>> ancestry('/a/b/c') ['/', '/a', '/a/b'] + + The argument should *not* be the result of a call to `syspath`. """ pathmod = pathmod or os.path out = [] @@ -223,8 +229,11 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')): def components(path, pathmod=None): """Return a list of the path components in path. For instance: + >>> components('/a/b/c') ['a', 'b', 'c'] + + The argument should *not* be the result of a call to `syspath`. """ pathmod = pathmod or os.path comps = [] @@ -242,15 +251,10 @@ def components(path, pathmod=None): return comps -def bytestring_path(path): - """Given a path, which is either a str or a unicode, returns a str - path (ensuring that we never deal with Unicode pathnames). +def _fsencoding(): + """Get the system's filesystem encoding. On Windows, this is always + UTF-8 (not MBCS). """ - # Pass through bytestrings. - if isinstance(path, str): - return path - - # Try to encode with default encodings, but fall back to UTF8. encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() if encoding == 'mbcs': # On Windows, a broken encoding known to Python as "MBCS" is @@ -259,8 +263,28 @@ def bytestring_path(path): # we can avoid dealing with this nastiness. We arbitrarily # choose UTF-8. encoding = 'utf8' + return encoding + +def bytestring_path(path, pathmod=None): + """Given a path, which is either a str or a unicode, returns a str + path (ensuring that we never deal with Unicode pathnames). + """ + pathmod = pathmod or os.path + windows = pathmod.__name__ == 'ntpath' + + # Pass through bytestrings. + if isinstance(path, str): + return path + + # On Windows, remove the magic prefix added by `syspath`. This makes + # ``bytestring_path(syspath(X)) == X``, i.e., we can safely + # round-trip through `syspath`. + if windows and path.startswith(WINDOWS_MAGIC_PREFIX): + path = path[len(WINDOWS_MAGIC_PREFIX):] + + # Try to encode with default encodings, but fall back to UTF8. try: - return path.encode(encoding) + return path.encode(_fsencoding()) except (UnicodeError, LookupError): return path.encode('utf8') @@ -274,9 +298,8 @@ def displayable_path(path): # A non-string object: just get its unicode representation. return unicode(path) - encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() try: - return path.decode(encoding, 'ignore') + return path.decode(_fsencoding(), 'ignore') except (UnicodeError, LookupError): return path.decode('utf8', 'ignore') @@ -305,8 +328,8 @@ def syspath(path, pathmod=None): path = path.decode(encoding, 'replace') # Add the magic prefix if it isn't already there - if not path.startswith(u'\\\\?\\'): - path = u'\\\\?\\' + path + if not path.startswith(WINDOWS_MAGIC_PREFIX): + path = WINDOWS_MAGIC_PREFIX + path return path @@ -469,10 +492,14 @@ def str2bool(value): def as_string(value): """Convert a value to a Unicode object for matching with a query. - None becomes the empty string. + None becomes the empty string. Bytestrings are silently decoded. """ if value is None: return u'' + elif isinstance(value, buffer): + return str(value).decode('utf8', 'ignore') + elif isinstance(value, str): + return value.decode('utf8', 'ignore') else: return unicode(value) @@ -520,3 +547,29 @@ def plurality(objs): res = obj return res, max_freq + +def cpu_count(): + """Return the number of hardware thread contexts (cores or SMT + threads) in the system. + """ + # Adapted from the soundconverter project: + # https://github.com/kassoulet/soundconverter + if sys.platform == 'win32': + try: + num = int(os.environ['NUMBER_OF_PROCESSORS']) + except (ValueError, KeyError): + num = 0 + elif sys.platform == 'darwin': + try: + num = int(os.popen('sysctl -n hw.ncpu').read()) + except ValueError: + num = 0 + else: + try: + num = os.sysconf('SC_NPROCESSORS_ONLN') + except (ValueError, OSError, AttributeError): + num = 0 + if num >= 1: + return num + else: + return 1 diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index b81db3c7f..8d2bb0dd1 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -304,11 +304,11 @@ class Pipeline(object): raise ValueError('pipeline must have at least two stages') self.stages = [] for stage in stages: - if isinstance(stage, types.GeneratorType): + if isinstance(stage, (list, tuple)): + self.stages.append(stage) + else: # Default to one thread per stage. self.stages.append((stage,)) - else: - self.stages.append(stage) def run_sequential(self): """Run the pipeline sequentially in the current thread. The @@ -432,7 +432,7 @@ if __name__ == '__main__': print('processing %i' % num) time.sleep(3) if num == 3: - raise Exception() + raise Exception() num = yield num * 2 def exc_consume(): while True: diff --git a/beetsplug/convert.py b/beetsplug/convert.py new file mode 100644 index 000000000..4ea3b60e3 --- /dev/null +++ b/beetsplug/convert.py @@ -0,0 +1,144 @@ +# This file is part of beets. +# Copyright 2012, Jakob Schnitzer. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Converts tracks or albums to external directory +""" +import logging +import os +import shutil +import threading +from subprocess import Popen, PIPE + +from beets.plugins import BeetsPlugin +from beets import ui, library, util +from beetsplug.embedart import _embed + +log = logging.getLogger('beets') +DEVNULL = open(os.devnull, 'wb') +conf = {} +_fs_lock = threading.Lock() + + +def encode(source, dest): + log.info(u'Started encoding {0}'.format(util.displayable_path(source))) + temp_dest = dest + '~' + + source_ext = os.path.splitext(source)[1].lower() + if source_ext == '.flac': + decode = Popen([conf['flac'], '-c', '-d', '-s', source], + stdout=PIPE) + encode = Popen([conf['lame']] + conf['opts'] + ['-', temp_dest], + stdin=decode.stdout, stderr=DEVNULL) + decode.stdout.close() + encode.communicate() + elif source_ext == '.mp3': + encode = Popen([conf['lame']] + conf['opts'] + ['--mp3input'] + + [source, temp_dest], close_fds=True, stderr=DEVNULL) + encode.communicate() + else: + log.error(u'Only converting from FLAC or MP3 implemented') + return + if encode.returncode != 0: + # Something went wrong (probably Ctrl+C), remove temporary files + log.info(u'Encoding {0} failed. Cleaning up...'.format(source)) + util.remove(temp_dest) + util.prune_dirs(os.path.dirname(temp_dest)) + return + shutil.move(temp_dest, dest) + log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) + + +def convert_item(lib, dest_dir): + while True: + item = yield + if item.format != 'FLAC' and item.format != 'MP3': + log.info(u'Skipping {0} (unsupported format)'.format( + util.displayable_path(item.path) + )) + continue + + dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) + dest = os.path.splitext(dest)[0] + '.mp3' + + if os.path.exists(dest): + log.info(u'Skipping {0} (target file exists)'.format( + util.displayable_path(item.path) + )) + continue + + # Ensure that only one thread tries to create directories at a + # time. (The existence check is not atomic with the directory + # creation inside this function.) + with _fs_lock: + util.mkdirall(dest) + + if item.format == 'MP3' and item.bitrate < 1000 * conf['max_bitrate']: + log.info(u'Copying {0}'.format(util.displayable_path(item.path))) + util.copy(item.path, dest) + else: + encode(item.path, dest) + + item.path = dest + item.write() + + artpath = lib.get_album(item).artpath + if artpath and conf['embed']: + _embed(artpath, [item]) + + +def convert_func(lib, config, opts, args): + dest = opts.dest if opts.dest is not None else conf['dest'] + if not dest: + raise ui.UserError('no convert destination set') + threads = opts.threads if opts.threads is not None else conf['threads'] + + ui.commands.list_items(lib, ui.decargs(args), opts.album, None, config) + + if not ui.input_yn("Convert? (Y/n)"): + return + + if opts.album: + items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) + else: + items = lib.items(ui.decargs(args)) + convert = [convert_item(lib, dest) for i in range(threads)] + pipe = util.pipeline.Pipeline([items, convert]) + pipe.run_parallel() + + +class ConvertPlugin(BeetsPlugin): + def configure(self, config): + conf['dest'] = ui.config_val(config, 'convert', 'dest', None) + conf['threads'] = int(ui.config_val(config, 'convert', 'threads', + util.cpu_count())) + conf['flac'] = ui.config_val(config, 'convert', 'flac', 'flac') + conf['lame'] = ui.config_val(config, 'convert', 'lame', 'lame') + conf['opts'] = ui.config_val(config, 'convert', + 'opts', '-V2').split(' ') + conf['max_bitrate'] = int(ui.config_val(config, 'convert', + 'max_bitrate', '500')) + conf['embed'] = ui.config_val(config, 'convert', 'embed', True, + vtype=bool) + + def commands(self): + cmd = ui.Subcommand('convert', help='convert to external location') + cmd.parser.add_option('-a', '--album', action='store_true', + help='choose albums instead of tracks') + cmd.parser.add_option('-t', '--threads', action='store', type='int', + help='change the number of threads, \ + defaults to maximum availble processors ') + cmd.parser.add_option('-d', '--dest', action='store', + help='set the destination directory') + cmd.func = convert_func + return [cmd] diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 68906c1c4..19bf787e7 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -117,10 +117,11 @@ def art_in_path(path): for ext in IMAGE_EXTENSIONS: if fn.lower().endswith('.' + ext): images.append(fn) + images.sort() # Look for "preferred" filenames. - for fn in images: - for name in COVER_NAMES: + for name in COVER_NAMES: + for fn in images: if fn.lower().startswith(name): log.debug('Using well-named art file %s' % fn) return os.path.join(path, fn) diff --git a/beetsplug/fuzzy_search.py b/beetsplug/fuzzy_search.py index dfdb4b296..37a664ac7 100644 --- a/beetsplug/fuzzy_search.py +++ b/beetsplug/fuzzy_search.py @@ -16,7 +16,7 @@ """ import beets from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, decargs, print_ +from beets.ui import Subcommand, decargs, print_obj from beets.util.functemplate import Template import difflib @@ -24,19 +24,18 @@ import difflib # THRESHOLD = 0.7 -def fuzzy_score(query, item): - return difflib.SequenceMatcher(a=query, b=item).quick_ratio() +def fuzzy_score(queryMatcher, item): + queryMatcher.set_seq1(item) + return queryMatcher.quick_ratio() -def is_match(query, item, album=False, verbose=False, threshold=0.7): - query = ' '.join(query) - +def is_match(queryMatcher, item, album=False, verbose=False, threshold=0.7): if album: values = [item.albumartist, item.album] else: values = [item.artist, item.album, item.title] - s = max(fuzzy_score(query.lower(), i.lower()) for i in values) + s = max(fuzzy_score(queryMatcher, i.lower()) for i in values) if verbose: return (s >= threshold, s) else: @@ -45,36 +44,32 @@ def is_match(query, item, album=False, verbose=False, threshold=0.7): def fuzzy_list(lib, config, opts, args): query = decargs(args) - fmt = opts.format + query = ' '.join(query).lower() + queryMatcher = difflib.SequenceMatcher(b=query) + if opts.threshold is not None: threshold = float(opts.threshold) else: threshold = float(conf['threshold']) - if fmt is None: - # If no specific template is supplied, use a default - if opts.album: - fmt = u'$albumartist - $album' - else: - fmt = u'$artist - $album - $title' - template = Template(fmt) + if opts.path: + fmt = '$path' + else: + fmt = opts.format + template = Template(fmt) if fmt else None if opts.album: objs = lib.albums() else: objs = lib.items() - items = filter(lambda i: is_match(query, i, album=opts.album, + items = filter(lambda i: is_match(queryMatcher, i, album=opts.album, threshold=threshold), objs) - for i in items: - if opts.path: - print_(i.item_dir() if opts.album else i.path) - elif opts.album: - print_(i.evaluate_template(template)) - else: - print_(i.evaluate_template(template, lib)) + + for item in items: + print_obj(item, lib, config, template) if opts.verbose: - print(is_match(query, i, album=opts.album, verbose=True)[1]) + print(is_match(queryMatcher, i, album=opts.album, verbose=True)[1]) fuzzy_cmd = Subcommand('fuzzy', diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py new file mode 100644 index 000000000..eb427a825 --- /dev/null +++ b/beetsplug/ihate.py @@ -0,0 +1,142 @@ +# This file is part of beets. +# Copyright 2012, Blemjhoo Tezoulbr . +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Warns you about things you hate (or even blocks import).""" + +import re +import logging +from beets.plugins import BeetsPlugin +from beets import ui +from beets.importer import action + + +__author__ = 'baobab@heresiarch.info' +__version__ = '1.0' + + +class IHatePlugin(BeetsPlugin): + + _instance = None + _log = logging.getLogger('beets') + + warn_genre = [] + warn_artist = [] + warn_album = [] + warn_whitelist = [] + skip_genre = [] + skip_artist = [] + skip_album = [] + skip_whitelist = [] + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(IHatePlugin, + cls).__new__(cls, *args, **kwargs) + return cls._instance + + def __str__(self): + return ('(\n warn_genre = {0}\n' + ' warn_artist = {1}\n' + ' warn_album = {2}\n' + ' warn_whitelist = {3}\n' + ' skip_genre = {4}\n' + ' skip_artist = {5}\n' + ' skip_album = {6}\n' + ' skip_whitelist = {7} )\n' + .format(self.warn_genre, self.warn_artist, self.warn_album, + self.warn_whitelist, self.skip_genre, self.skip_artist, + self.skip_album, self.skip_whitelist)) + + def configure(self, config): + if not config.has_section('ihate'): + self._log.debug('[ihate] plugin is not configured') + return + self.warn_genre = ui.config_val(config, 'ihate', 'warn_genre', + '').split() + self.warn_artist = ui.config_val(config, 'ihate', 'warn_artist', + '').split() + self.warn_album = ui.config_val(config, 'ihate', 'warn_album', + '').split() + self.warn_whitelist = ui.config_val(config, 'ihate', 'warn_whitelist', + '').split() + self.skip_genre = ui.config_val(config, 'ihate', 'skip_genre', + '').split() + self.skip_artist = ui.config_val(config, 'ihate', 'skip_artist', + '').split() + self.skip_album = ui.config_val(config, 'ihate', 'skip_album', + '').split() + self.skip_whitelist = ui.config_val(config, 'ihate', 'skip_whitelist', + '').split() + + @classmethod + def match_patterns(cls, s, patterns): + """Check if string is matching any of the patterns in the list.""" + for p in patterns: + if re.findall(p, s, flags=re.IGNORECASE): + return True + return False + + @classmethod + def do_i_hate_this(cls, task, genre_patterns, artist_patterns, + album_patterns, whitelist_patterns): + """Process group of patterns (warn or skip) and returns True if + task is hated and not whitelisted. + """ + hate = False + try: + genre = task.items[0].genre + except: + genre = u'' + if genre and genre_patterns: + if IHatePlugin.match_patterns(genre, genre_patterns): + hate = True + if not hate and task.cur_album and album_patterns: + if IHatePlugin.match_patterns(task.cur_album, album_patterns): + hate = True + if not hate and task.cur_artist and artist_patterns: + if IHatePlugin.match_patterns(task.cur_artist, artist_patterns): + hate = True + if hate and whitelist_patterns: + if IHatePlugin.match_patterns(task.cur_artist, whitelist_patterns): + hate = False + return hate + + def job_to_do(self): + """Return True if at least one pattern is defined.""" + return any([self.warn_genre, self.warn_artist, self.warn_album, + self.skip_genre, self.skip_artist, self.skip_album]) + + def import_task_choice_event(self, task, config): + if task.choice_flag == action.APPLY: + if self.job_to_do: + self._log.debug('[ihate] processing your hate') + if self.do_i_hate_this(task, self.skip_genre, self.skip_artist, + self.skip_album, self.skip_whitelist): + task.choice_flag = action.SKIP + self._log.info(u'[ihate] skipped: {0} - {1}' + .format(task.cur_artist, task.cur_album)) + return + if self.do_i_hate_this(task, self.warn_genre, self.warn_artist, + self.warn_album, self.warn_whitelist): + self._log.info(u'[ihate] you maybe hate this: {0} - {1}' + .format(task.cur_artist, task.cur_album)) + else: + self._log.debug('[ihate] nothing to do') + else: + self._log.debug('[ihate] user make a decision, nothing to do') + + +@IHatePlugin.listen('import_task_choice') +def ihate_import_task_choice(task, config): + IHatePlugin().import_task_choice_event(task, config) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 4d5cec51a..b915f815c 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -31,6 +31,7 @@ import os from beets import plugins from beets import ui from beets.util import normpath +from beets.ui import commands log = logging.getLogger('beets') @@ -166,6 +167,35 @@ class LastGenrePlugin(plugins.BeetsPlugin): fallback_str = ui.config_val(config, 'lastgenre', 'fallback_str', None) + def commands(self): + lastgenre_cmd = ui.Subcommand('lastgenre', help='fetch genres') + def lastgenre_func(lib, config, opts, args): + # The "write to files" option corresponds to the + # import_write config value. + write = ui.config_val(config, 'beets', 'import_write', + commands.DEFAULT_IMPORT_WRITE, bool) + for album in lib.albums(ui.decargs(args)): + tags = [] + lastfm_obj = LASTFM.get_album(album.albumartist, album.album) + if album.genre: + tags.append(album.genre) + + tags.extend(_tags_for(lastfm_obj)) + genre = _tags_to_genre(tags) + + if not genre and fallback_str != None: + genre = fallback_str + log.debug(u'no last.fm genre found: fallback to %s' % genre) + + if genre is not None: + log.debug(u'adding last.fm album genre: %s' % genre) + album.genre = genre + if write: + for item in album.items(): + item.write() + lastgenre_cmd.func = lastgenre_func + return [lastgenre_cmd] + def imported(self, config, task): tags = [] if task.is_album: diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 6269e38a1..2a801436e 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -44,7 +44,7 @@ def fetch_url(url): try: return urllib.urlopen(url).read() except IOError as exc: - log.debug('failed to fetch: {0} ({1})'.format(url, str(exc))) + log.debug(u'failed to fetch: {0} ({1})'.format(url, unicode(exc))) return None def unescape(text): @@ -160,7 +160,7 @@ def get_lyrics(artist, title): if lyrics: if isinstance(lyrics, str): lyrics = lyrics.decode('utf8', 'ignore') - log.debug('got lyrics from backend: {0}'.format(backend.__name__)) + log.debug(u'got lyrics from backend: {0}'.format(backend.__name__)) return lyrics diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 9e524f780..a79e0242a 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -20,7 +20,7 @@ from beets import ui import musicbrainzngs from musicbrainzngs import musicbrainz -SUBMISSION_CHUNK_SIZE = 350 +SUBMISSION_CHUNK_SIZE = 200 def submit_albums(collection_id, release_ids): """Add all of the release IDs to the indicated collection. Multiple diff --git a/beetsplug/rdm.py b/beetsplug/rdm.py index 63779e8d2..6eebba0bd 100644 --- a/beetsplug/rdm.py +++ b/beetsplug/rdm.py @@ -15,22 +15,17 @@ """Get a random song or album from the library. """ from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, decargs, print_ +from beets.ui import Subcommand, decargs, print_obj from beets.util.functemplate import Template import random def random_item(lib, config, opts, args): query = decargs(args) - path = opts.path - fmt = opts.format - - if fmt is None: - # If no specific template is supplied, use a default - if opts.album: - fmt = u'$albumartist - $album' - else: - fmt = u'$artist - $album - $title' - template = Template(fmt) + if opts.path: + fmt = '$path' + else: + fmt = opts.format + template = Template(fmt) if fmt else None if opts.album: objs = list(lib.albums(query=query)) @@ -39,18 +34,8 @@ def random_item(lib, config, opts, args): number = min(len(objs), opts.number) objs = random.sample(objs, number) - if opts.album: - for album in objs: - if path: - print_(album.item_dir()) - else: - print_(album.evaluate_template(template)) - else: - for item in objs: - if path: - print_(item.path) - else: - print_(item.evaluate_template(template, lib)) + for item in objs: + print_obj(item, lib, config, template) random_cmd = Subcommand('random', help='chose a random track or album') diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 195f3edfb..1f03a4ed8 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -1,128 +1,231 @@ -#Copyright (c) 2011, Peter Brunner (Lugoues) +# This file is part of beets. +# Copyright 2012, Fabrice Laporte. # -#Permission is hereby granted, free of charge, to any person obtaining a copy -#of this software and associated documentation files (the "Software"), to deal -#in the Software without restriction, including without limitation the rights -#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -#copies of the Software, and to permit persons to whom the Software is -#furnished to do so, subject to the following conditions: +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: # -#The above copyright notice and this permission notice shall be included in -#all copies or substantial portions of the Software. -# -#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -#THE SOFTWARE. +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. import logging - -from rgain import rgcalc +import subprocess +import os from beets import ui from beets.plugins import BeetsPlugin -from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beets.util import syspath +from beets.ui import commands log = logging.getLogger('beets') DEFAULT_REFERENCE_LOUDNESS = 89 +class ReplayGainError(Exception): + """Raised when an error occurs during mp3gain/aacgain execution. + """ + +def call(args): + """Execute the command indicated by `args` (a list of strings) and + return the command's output. The stderr stream is ignored. If the + command exits abnormally, a ReplayGainError is raised. + """ + try: + with open(os.devnull, 'w') as devnull: + return subprocess.check_output(args, stderr=devnull) + except subprocess.CalledProcessError as e: + raise ReplayGainError( + "{0} exited with status {1}".format(args[0], e.returncode) + ) + +def parse_tool_output(text): + """Given the tab-delimited output from an invocation of mp3gain + or aacgain, parse the text and return a list of dictionaries + containing information about each analyzed file. + """ + out = [] + for line in text.split('\n'): + parts = line.split('\t') + if len(parts) != 6 or parts[0] == 'File': + continue + out.append({ + 'file': parts[0], + 'mp3gain': int(parts[1]), + 'gain': float(parts[2]), + 'peak': float(parts[3]), + 'maxgain': int(parts[4]), + 'mingain': int(parts[5]), + }) + return out class ReplayGainPlugin(BeetsPlugin): - '''Provides replay gain analysis for the Beets Music Manager''' - - ref_level = DEFAULT_REFERENCE_LOUDNESS - overwrite = False - + """Provides ReplayGain analysis. + """ def __init__(self): - self.register_listener('album_imported', self.album_imported) - self.register_listener('item_imported', self.item_imported) + super(ReplayGainPlugin, self).__init__() + self.import_stages = [self.imported] def configure(self, config): - self.overwrite = ui.config_val(config, - 'replaygain', - 'overwrite', - False) + self.overwrite = ui.config_val(config, 'replaygain', + 'overwrite', False, bool) + self.albumgain = ui.config_val(config, 'replaygain', + 'albumgain', False, bool) + self.noclip = ui.config_val(config, 'replaygain', + 'noclip', True, bool) + self.apply_gain = ui.config_val(config, 'replaygain', + 'apply_gain', False, bool) + target_level = float(ui.config_val(config, 'replaygain', + 'targetlevel', + DEFAULT_REFERENCE_LOUDNESS)) + self.gain_offset = int(target_level - DEFAULT_REFERENCE_LOUDNESS) + self.automatic = ui.config_val(config, 'replaygain', + 'automatic', True, bool) - def album_imported(self, lib, album, config): - self.write_album = True + self.command = ui.config_val(config,'replaygain','command', None) + if self.command: + # Explicit executable path. + if not os.path.isfile(self.command): + raise ui.UserError( + 'replaygain command does not exist: {0}'.format( + self.command + ) + ) + else: + # Check whether the program is in $PATH. + for cmd in ('mp3gain', 'aacgain'): + try: + call([cmd, '-v']) + self.command = cmd + except OSError: + pass + if not self.command: + raise ui.UserError( + 'no replaygain command found: install mp3gain or aacgain' + ) - log.debug("Calculating ReplayGain for %s - %s" % \ - (album.albumartist, album.album)) + def imported(self, config, task): + """Our import stage function.""" + if not self.automatic: + return - try: - media_files = \ - [MediaFile(syspath(item.path)) for item in album.items()] - media_files = [mf for mf in media_files if self.requires_gain(mf)] + if task.is_album: + album = config.lib.get_album(task.album_id) + items = list(album.items()) + else: + items = [task.item] - #calculate gain. - #Return value - track_data: array dictionary indexed by filename - track_data, album_data = rgcalc.calculate( - [syspath(mf.path) for mf in media_files], - True, - self.ref_level) + results = self.compute_rgain(items, task.is_album) + if results: + self.store_gain(config.lib, items, results, + album if task.is_album else None) - for mf in media_files: - self.write_gain(mf, track_data, album_data) + def commands(self): + """Provide a ReplayGain command.""" + def func(lib, config, opts, args): + write = ui.config_val(config, 'beets', 'import_write', + commands.DEFAULT_IMPORT_WRITE, bool) - except (FileTypeError, UnreadableFileError, - TypeError, ValueError) as e: - log.error("failed to calculate replaygain: %s ", e) + if opts.album: + # Analyze albums. + for album in lib.albums(ui.decargs(args)): + log.info(u'analyzing {0} - {1}'.format(album.albumartist, + album.album)) + items = list(album.items()) + results = self.compute_rgain(items, True) + if results: + self.store_gain(lib, items, results, album) - def item_imported(self, lib, item, config): - try: - self.write_album = False + if write: + for item in items: + item.write() - mf = MediaFile(syspath(item.path)) - - if self.requires_gain(mf): - track_data, album_data = rgcalc.calculate([syspath(mf.path)], - True, - self.ref_level) - self.write_gain(mf, track_data, None) - except (FileTypeError, UnreadableFileError, - TypeError, ValueError) as e: - log.error("failed to calculate replaygain: %s ", e) - - def write_gain(self, mf, track_data, album_data): - try: - mf.rg_track_gain = track_data[syspath(mf.path)].gain - mf.rg_track_peak = track_data[syspath(mf.path)].peak - - if self.write_album and album_data: - mf.rg_album_gain = album_data.gain - mf.rg_album_peak = album_data.peak - - log.debug('Tagging ReplayGain for: %s - %s \n' - '\tTrack Gain = %f\n' - '\tTrack Peak = %f\n' - '\tAlbum Gain = %f\n' - '\tAlbum Peak = %f' % \ - (mf.artist, - mf.title, - mf.rg_track_gain, - mf.rg_track_peak, - mf.rg_album_gain, - mf.rg_album_peak)) else: - log.debug('Tagging ReplayGain for: %s - %s \n' - '\tTrack Gain = %f\n' - '\tTrack Peak = %f\n' % \ - (mf.artist, - mf.title, - mf.rg_track_gain, - mf.rg_track_peak)) + # Analyze individual tracks. + for item in lib.items(ui.decargs(args)): + log.info(u'analyzing {0} - {1}'.format(item.artist, + item.title)) + results = self.compute_rgain([item], False) + if results: + self.store_gain(lib, [item], results, None) - mf.save() - except (FileTypeError, UnreadableFileError, TypeError, ValueError): - log.error("failed to write replaygain: %s" % (mf.title)) + if write: + item.write() - def requires_gain(self, mf): + cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain') + cmd.parser.add_option('-a', '--album', action='store_true', + help='analyze albums instead of tracks') + cmd.func = func + return [cmd] + + def requires_gain(self, item, album=False): + """Does the gain need to be computed?""" return self.overwrite or \ - (not mf.rg_track_gain or not mf.rg_track_peak) or \ - ((not mf.rg_album_gain or not mf.rg_album_peak) and \ - self.write_album) + (not item.rg_track_gain or not item.rg_track_peak) or \ + ((not item.rg_album_gain or not item.rg_album_peak) and \ + album) + + def compute_rgain(self, items, album=False): + """Compute ReplayGain values and return a list of results + dictionaries as given by `parse_tool_output`. + """ + # Skip calculating gain only when *all* files don't need + # recalculation. This way, if any file among an album's tracks + # needs recalculation, we still get an accurate album gain + # value. + if all([not self.requires_gain(i, album) for i in items]): + log.debug(u'replaygain: no gain to compute') + return + + # Construct shell command. The "-o" option makes the output + # easily parseable (tab-delimited). "-s s" forces gain + # recalculation even if tags are already present and disables + # tag-writing; this turns the mp3gain/aacgain tool into a gain + # calculator rather than a tag manipulator because we take care + # of changing tags ourselves. + cmd = [self.command, '-o', '-s', 's'] + if self.noclip: + # Adjust to avoid clipping. + cmd = cmd + ['-k'] + else: + # Disable clipping warning. + cmd = cmd + ['-c'] + if self.apply_gain: + # Lossless audio adjustment. + cmd = cmd + ['-a' if album and self.albumgain else '-r'] + cmd = cmd + ['-d', str(self.gain_offset)] + cmd = cmd + [syspath(i.path) for i in items] + + log.debug(u'replaygain: analyzing {0} files'.format(len(items))) + output = call(cmd) + log.debug(u'replaygain: analysis finished') + results = parse_tool_output(output) + + return results + + def store_gain(self, lib, items, rgain_infos, album=None): + """Store computed ReplayGain values to the Items and the Album + (if it is provided). + """ + for item, info in zip(items, rgain_infos): + item.rg_track_gain = info['gain'] + item.rg_track_peak = info['peak'] + lib.store(item) + + log.debug(u'replaygain: applied track gain {0}, peak {1}'.format( + item.rg_track_gain, + item.rg_track_peak + )) + + if album and self.albumgain: + assert len(rgain_infos) == len(items) + 1 + album_info = rgain_infos[-1] + album.rg_album_gain = album_info['gain'] + album.rg_album_peak = album_info['peak'] + log.debug(u'replaygain: applied album gain {0}, peak {1}'.format( + album.rg_album_gain, + album.rg_album_peak + )) diff --git a/beetsplug/the.py b/beetsplug/the.py index ab3e9f0a4..da9450f7e 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -14,118 +14,106 @@ """Moves patterns in path formats (suitable for moving articles).""" -from __future__ import print_function -import sys import re +import logging from beets.plugins import BeetsPlugin from beets import ui __author__ = 'baobab@heresiarch.info' -__version__ = '1.0' +__version__ = '1.1' PATTERN_THE = u'^[the]{3}\s' PATTERN_A = u'^[a][n]?\s' FORMAT = u'{0}, {1}' -the_options = { - 'debug': False, - 'the': True, - 'a': True, - 'format': FORMAT, - 'strip': False, - 'silent': False, - 'patterns': [PATTERN_THE, PATTERN_A], -} - - class ThePlugin(BeetsPlugin): + _instance = None + _log = logging.getLogger('beets') + + the = True + a = True + format = u'' + strip = False + patterns = [] + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(ThePlugin, + cls).__new__(cls, *args, **kwargs) + return cls._instance + + def __str__(self): + return ('[the]\n the = {0}\n a = {1}\n format = {2}\n' + ' strip = {3}\n patterns = {4}' + .format(self.the, self.a, self.format, self.strip, + self.patterns)) + def configure(self, config): if not config.has_section('the'): - print('[the] plugin is not configured, using defaults', - file=sys.stderr) + self._log.debug(u'[the] plugin is not configured, using defaults') return - self.in_config = True - the_options['debug'] = ui.config_val(config, 'the', 'debug', False, - bool) - the_options['the'] = ui.config_val(config, 'the', 'the', True, bool) - the_options['a'] = ui.config_val(config, 'the', 'a', True, bool) - the_options['format'] = ui.config_val(config, 'the', 'format', - FORMAT) - the_options['strip'] = ui.config_val(config, 'the', 'strip', False, - bool) - the_options['silent'] = ui.config_val(config, 'the', 'silent', False, - bool) - the_options['patterns'] = ui.config_val(config, 'the', 'patterns', - '').split() - for p in the_options['patterns']: + self.the = ui.config_val(config, 'the', 'the', True, bool) + self.a = ui.config_val(config, 'the', 'a', True, bool) + self.format = ui.config_val(config, 'the', 'format', FORMAT) + self.strip = ui.config_val(config, 'the', 'strip', False, bool) + self.patterns = ui.config_val(config, 'the', 'patterns', '').split() + for p in self.patterns: if p: try: re.compile(p) except re.error: - print(u'[the] invalid pattern: {0}'.format(p), - file=sys.stderr) + self._log.error(u'[the] invalid pattern: {0}'.format(p)) else: if not (p.startswith('^') or p.endswith('$')): - if not the_options['silent']: - print(u'[the] warning: pattern \"{0}\" will not ' - 'match string start/end'.format(p), - file=sys.stderr) - if the_options['a']: - the_options['patterns'] = [PATTERN_A] + the_options['patterns'] - if the_options['the']: - the_options['patterns'] = [PATTERN_THE] + the_options['patterns'] - if not the_options['patterns'] and not the_options['silent']: - print('[the] no patterns defined!') - if the_options['debug']: - print(u'[the] patterns: {0}' - .format(' '.join(the_options['patterns'])), file=sys.stderr) + self._log.warn(u'[the] warning: \"{0}\" will not ' + 'match string start/end'.format(p)) + if self.a: + self.patterns = [PATTERN_A] + self.patterns + if self.the: + self.patterns = [PATTERN_THE] + self.patterns + if not self.patterns: + self._log.warn(u'[the] no patterns defined!') -def unthe(text, pattern, strip=False): - """Moves pattern in the path format string or strips it + def unthe(self, text, pattern): + """Moves pattern in the path format string or strips it - text -- text to handle - pattern -- regexp pattern (case ignore is already on) - strip -- if True, pattern will be removed + text -- text to handle + pattern -- regexp pattern (case ignore is already on) + strip -- if True, pattern will be removed - """ - if text: - r = re.compile(pattern, flags=re.IGNORECASE) - try: - t = r.findall(text)[0] - except IndexError: - return text - else: - r = re.sub(r, '', text).strip() - if strip: - return r + """ + if text: + r = re.compile(pattern, flags=re.IGNORECASE) + try: + t = r.findall(text)[0] + except IndexError: + return text else: - return the_options['format'].format(r, t.strip()).strip() - else: - return u'' + r = re.sub(r, '', text).strip() + if self.strip: + return r + else: + return self.format.format(r, t.strip()).strip() + else: + return u'' + def the_template_func(self, text): + if not self.patterns: + return text + if text: + for p in self.patterns: + r = self.unthe(text, p) + if r != text: + break + self._log.debug(u'[the] \"{0}\" -> \"{1}\"'.format(text, r)) + return r + else: + return u'' @ThePlugin.template_func('the') def func_the(text): """Provides beets template function %the""" - if not the_options['patterns']: - return text - if text: - for p in the_options['patterns']: - r = unthe(text, p, the_options['strip']) - if r != text: - break - if the_options['debug']: - print(u'[the] \"{0}\" -> \"{1}\"'.format(text, r), file=sys.stderr) - return r - else: - return u'' - - -# simple tests -if __name__ == '__main__': - print(unthe('The The', PATTERN_THE)) - print(unthe('An Apple', PATTERN_A)) - print(unthe('A Girl', PATTERN_A, strip=True)) + return ThePlugin().the_template_func(text) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 01e0cd102..9c22b6d2d 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -14,9 +14,8 @@ """ Clears tag fields in media files.""" -from __future__ import print_function -import sys import re +import logging from beets.plugins import BeetsPlugin from beets import ui from beets.library import ITEM_KEYS @@ -24,14 +23,14 @@ from beets.importer import action __author__ = 'baobab@heresiarch.info' -__version__ = '0.9' +__version__ = '0.10' class ZeroPlugin(BeetsPlugin): _instance = None + _log = logging.getLogger('beets') - debug = False fields = [] patterns = {} warned = False @@ -43,25 +42,16 @@ class ZeroPlugin(BeetsPlugin): return cls._instance def __str__(self): - return ('[zero]\n debug = {0}\n fields = {1}\n patterns = {2}\n' - ' warned = {3}'.format(self.debug, self.fields, self.patterns, - self.warned)) - - def dbg(self, *args): - """Prints message to stderr.""" - if self.debug: - print('[zero]', *args, file=sys.stderr) + return ('[zero]\n fields = {0}\n patterns = {1}\n warned = {2}' + .format(self.fields, self.patterns, self.warned)) def configure(self, config): if not config.has_section('zero'): - self.dbg('plugin is not configured') + self._log.debug('[zero] plugin is not configured') return - self.debug = ui.config_val(config, 'zero', 'debug', True, bool) for f in ui.config_val(config, 'zero', 'fields', '').split(): if f not in ITEM_KEYS: - self.dbg( - 'invalid field \"{0}\" (try \'beet fields\')'.format(f) - ) + self._log.error('[zero] invalid field: {0}'.format(f)) else: self.fields.append(f) p = ui.config_val(config, 'zero', f, '').split() @@ -69,15 +59,11 @@ class ZeroPlugin(BeetsPlugin): self.patterns[f] = p else: self.patterns[f] = ['.'] - if self.debug: - print(self, file=sys.stderr) def import_task_choice_event(self, task, config): """Listen for import_task_choice event.""" - if self.debug: - self.dbg('listen: import_task_choice') if task.choice_flag == action.ASIS and not self.warned: - self.dbg('cannot zero in \"as-is\" mode') + self._log.warn('[zero] cannot zero in \"as-is\" mode') self.warned = True # TODO request write in as-is mode @@ -93,25 +79,24 @@ class ZeroPlugin(BeetsPlugin): def write_event(self, item): """Listen for write event.""" - if self.debug: - self.dbg('listen: write') if not self.fields: - self.dbg('no fields, nothing to do') + self._log.warn('[zero] no fields, nothing to do') return for fn in self.fields: try: fval = getattr(item, fn) except AttributeError: - self.dbg('? no such field: {0}'.format(fn)) + self._log.error('[zero] no such field: {0}'.format(fn)) else: if not self.match_patterns(fval, self.patterns[fn]): - self.dbg('\"{0}\" ({1}) is not match any of: {2}' - .format(fval, fn, ' '.join(self.patterns[fn]))) + self._log.debug('[zero] \"{0}\" ({1}) not match: {2}' + .format(fval, fn, + ' '.join(self.patterns[fn]))) continue - self.dbg('\"{0}\" ({1}) match: {2}' - .format(fval, fn, ' '.join(self.patterns[fn]))) + self._log.debug('[zero] \"{0}\" ({1}) match: {2}' + .format(fval, fn, ' '.join(self.patterns[fn]))) setattr(item, fn, type(fval)()) - self.dbg('{0}={1}'.format(fn, getattr(item, fn))) + self._log.debug('[zero] {0}={1}'.format(fn, getattr(item, fn))) @ZeroPlugin.listen('import_task_choice') @@ -121,9 +106,3 @@ def zero_choice(task, config): @ZeroPlugin.listen('write') def zero_write(item): ZeroPlugin().write_event(item) - - -# simple test -if __name__ == '__main__': - print(ZeroPlugin().match_patterns('test', ['[0-9]'])) - print(ZeroPlugin().match_patterns('test', ['.'])) diff --git a/docs/changelog.rst b/docs/changelog.rst index ceec1f286..6f21b72c2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,9 @@ Changelog 1.0b16 (in development) ----------------------- +* New plugin: :doc:`/plugins/convert` transcodes music and embeds album art + while copying to a separate directory. Thanks to Jakob Schnitzer and Andrew G. + Dunn. * New plugin: :doc:`/plugins/fuzzy_search` lets you find albums and tracks using fuzzy string matching so you don't have to type (or even remember) their exact names. Thanks to Philippe Mongeau. @@ -11,15 +14,32 @@ Changelog text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr. * New plugin: :doc:`/plugins/zero` filters out undesirable fields before they are written to your tags. Thanks again to Blemjhoo Tezoulbr. +* New plugin: :doc:`/plugins/ihate` automatically skips (or warns you about) + importing albums that match certain criteria. Thanks once again to Blemjhoo + Tezoulbr. +* :doc:`/plugins/replaygain`: This plugin has been completely overhauled to use + the `mp3gain`_ or `aacgain`_ command-line tools instead of the failure-prone + Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte. * :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file rather than just one. For example, if your FLAC file has both ordinary FLAC tags and ID3 tags, the ID3 tags are now also removed. -* ``list`` command: Templates given with ``-f`` can now show items' paths (using - ``$path``). +* :ref:`stats-cmd` command: New ``--exact`` switch to make the file size + calculation more accurate (thanks to Jakob Schnitzer). +* :ref:`list-cmd` command: Templates given with ``-f`` can now show items' and + albums' paths (using ``$path``). +* The output of the :ref:`update-cmd`, :ref:`remove-cmd`, and :ref:`modify-cmd` + commands now respects the :ref:`list_format_album` and + :ref:`list_format_item` config options. Thanks to Mike Kazantsev. * Fix album queries for ``artpath`` and other non-item fields. * Null values in the database can now be matched with the empty-string regular expression, ``^$``. * Queries now correctly match non-string values in path format predicates. +* :doc:`/plugins/lastgenre`: Use the albums' existing genre tags if they pass + the whitelist (thanks to Fabrice Laporte). +* :doc:`/plugins/lastgenre`: Add a ``lastgenre`` command for fetching genres + post facto (thanks to Jakob Schnitzer). +* :doc:`/plugins/fetchart`: Local image filenames are now used in alphabetical + order. * :doc:`/plugins/fetchart`: Fix a bug where cover art filenames could lack a ``.jpg`` extension. * :doc:`/plugins/lyrics`: Fix an exception with non-ASCII lyrics. @@ -29,16 +49,30 @@ Changelog than just being called "file" (thanks to Zach Denton). * :doc:`/plugins/importfeeds`: Fix error in symlink mode with non-ASCII filenames. +* :doc:`/plugins/mbcollection`: Fix an error when submitting a large number of + releases (we now submit only 200 releases at a time instead of 350). Thanks + to Jonathan Towne. * Add the track mapping dictionary to the ``album_distance`` plugin function. +* When an exception is raised while reading a file, the path of the file in + question is now logged (thanks to Mike Kazantsev). * Fix an assertion failure when the MusicBrainz main database and search server disagree. * Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to modify files' tags even when they successfully change the database. * Fix a VFS bug leading to a crash in the :doc:`/plugins/bpd` when files had non-ASCII extensions. +* Fix for changing date fields (like "year") with the :ref:`modify-cmd` + command. +* Fix a crash when input is read from a pipe without a specified encoding. +* Fix some problem with identifying files on Windows with Unicode directory + names in their path. * Add a human-readable error message when writing files' tags fails. +* Changed plugin loading so that modules can be imported without + unintentionally loading the plugins they contain. .. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html +.. _mp3gain: http://mp3gain.sourceforge.net/download.php +.. _aacgain: http://aacgain.altosdesign.com 1.0b15 (July 26, 2012) ---------------------- diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst new file mode 100644 index 000000000..47a4d5621 --- /dev/null +++ b/docs/plugins/convert.rst @@ -0,0 +1,62 @@ +Convert Plugin +============== + +The ``convert`` plugin lets you convert parts of your collection to a directory +of your choice. Currently only converting from MP3 or FLAC to MP3 is supported. +It will skip files that are already present in the target directory. Converted +files follow the same path formats as your library. + +Installation +------------ + +First, enable the ``convert`` plugin (see :doc:`/plugins/index`). + +To transcode music, this plugin requires the ``flac`` and ``lame`` command-line +tools. If those executables are in your path, they will be found automatically +by the plugin. Otherwise, configure the plugin to locate the executables:: + + [convert] + flac: /usr/bin/flac + lame: /usr/bin/lame + +Usage +----- + +To convert a part of your collection, run ``beet convert QUERY``. This +will display all items matching ``QUERY`` and ask you for confirmation before +starting the conversion. The ``-a`` (or ``--album``) option causes the command +to match albums instead of tracks. + +The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify +or overwrite the respective configuration options. + +Configuration +------------- + +The plugin offers several configuration options, all of which live under the +``[convert]`` section: + +* ``dest`` sets the directory the files will be converted (or copied) to. + A destination is required---you either have to provide it in the config file + or on the command line using the ``-d`` flag. +* ``embed`` indicates whether or not to embed album art in converted items. + Default: true. +* If you set ``max_bitrate``, all MP3 files with a higher bitrate will be + transcoded and those with a lower bitrate will simply be copied. Note that + this does not guarantee that all converted files will have a lower + bitrate---that depends on the encoder and its configuration. By default, FLAC + files will be converted and all MP3s will be copied without transcoding. +* ``opts`` are the encoding options that are passed to ``lame``. Default: + "-V2". Please refer to the LAME documentation for possible options. +* Finally, ``threads`` determines the number of threads to use for parallel + encoding. By default, the plugin will detect the number of processors + available and use them all. + +Here's an example configuration:: + + [convert] + embed: false + max_bitrate: 200 + opts: -V4 + dest: /home/user/MusicForPhone + threads: 4 diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst new file mode 100644 index 000000000..24e46fd14 --- /dev/null +++ b/docs/plugins/ihate.rst @@ -0,0 +1,35 @@ +IHate Plugin +============ + +The ``ihate`` plugin allows you to automatically skip things you hate during +import or warn you about them. It supports album, artist and genre patterns. +Also there is whitelist to avoid skipping bands you still like. There are two +groups: warn and skip. Skip group is checked first. Whitelist overrides any +other patterns. + +To use plugin, enable it by including ``ihate`` into ``plugins`` line of +your beets config:: + + [beets] + plugins = ihate + +You need to configure plugin before use, so add following section into config +file and adjust it to your needs:: + + [ihate] + # you will be warned about these suspicious genres/artists (regexps): + warn_genre=rnb soul power\smetal + warn_artist=bad\band another\sbad\sband + warn_album=tribute\sto + # if you don't like genre in general, but accept some band playing it, + # add exceptions here: + warn_whitelist=hate\sexception + # never import any of this: + skip_genre=russian\srock polka + skip_artist=manowar + skip_album=christmas + # but import this: + skip_whitelist= + +Note: plugin will trust you decision in 'as-is' mode. + \ No newline at end of file diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 757d8a3ac..cd94cf104 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -53,6 +53,8 @@ disabled by default, but you can turn them on as described above. the fuzzy_search zero + ihate + convert Autotagger Extensions '''''''''''''''''''''' @@ -92,8 +94,10 @@ Miscellaneous * :doc:`rdm`: Randomly choose albums and tracks from your library. * :doc:`fuzzy_search`: Search albums and tracks with fuzzy string matching. * :doc:`mbcollection`: Maintain your MusicBrainz collection list. +* :doc:`ihate`: Skip by defined patterns things you hate during import process. * :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is compatible with `MPD clients`_. +* :doc:`convert`: Converts parts of your collection to an external directory .. _MPD: http://mpd.wikia.com/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index accd7c819..aad040076 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -65,3 +65,11 @@ tree. .. _YAML: http://www.yaml.org/ .. _pyyaml: http://pyyaml.org/ + + +Running Manually +---------------- + +In addition to running automatically on import, the plugin can also run manually +from the command line. Use the command ``beet lastgenre [QUERY]`` to fetch +genres for albums matching a certain query. diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 393589cd1..9b83765f6 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -4,41 +4,29 @@ ReplayGain Plugin This plugin adds support for `ReplayGain`_, a technique for normalizing audio playback levels. -.. warning:: - - Some users have reported problems with the Gstreamer ReplayGain calculation - plugin. If you experience segmentation faults or random hangs with this - plugin enabled, consider disabling it. (Please `file a bug`_ if you can get - a gdb traceback for such a segfault or hang.) - - .. _file a bug: http://code.google.com/p/beets/issues/entry +.. _ReplayGain: http://wiki.hydrogenaudio.org/index.php?title=ReplayGain Installation ------------ -This plugin requires `GStreamer`_ with the `rganalysis`_ plugin (part of -`gst-plugins-good`_), `gst-python`_, and the `rgain`_ Python module. +This plugin uses the `mp3gain`_ command-line tool or the `aacgain`_ fork +thereof. To get started, install this tool: -.. _ReplayGain: http://wiki.hydrogenaudio.org/index.php?title=ReplayGain -.. _rganalysis: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-good-plugins/html/gst-plugins-good-plugins-rganalysis.html -.. _gst-plugins-good: http://gstreamer.freedesktop.org/modules/gst-plugins-good.html -.. _gst-python: http://gstreamer.freedesktop.org/modules/gst-python.html -.. _rgain: https://github.com/cacack/rgain -.. _pip: http://www.pip-installer.org/ -.. _GStreamer: http://gstreamer.freedesktop.org/ +* On Mac OS X, you can use `Homebrew`_. Type ``brew install aacgain``. +* On Linux, `mp3gain`_ is probably in your repositories. On Debian or Ubuntu, + for example, you can run ``apt-get install mp3gain``. +* On Windows, download and install the original `mp3gain`_. -First, install GStreamer, its "good" plugins, and the Python bindings if your -system doesn't have them already. (The :doc:`/plugins/bpd` and -:doc:`/plugins/chroma` pages have hints on getting GStreamer stuff installed.) -Then install `rgain`_ using `pip`_:: +.. _mp3gain: http://mp3gain.sourceforge.net/download.php +.. _aacgain: http://aacgain.altosdesign.com +.. _Homebrew: http://mxcl.github.com/homebrew/ - $ pip install rgain +Then enable the ``replaygain`` plugin (see :doc:`/reference/config`). If beets +doesn't automatically find the ``mp3gain`` or ``aacgain`` executable, you can +configure the path explicitly like so:: -Finally, add ``replaygain`` to your ``plugins`` line in your -:doc:`/reference/config`, like so:: - - [beets] - plugins = replaygain + [replaygain] + command: /Applications/MacMP3Gain.app/Contents/Resources/aacgain Usage & Configuration --------------------- @@ -53,3 +41,41 @@ for the plugin in your :doc:`/reference/config`, like so:: [replaygain] overwrite: yes + +The target level can be modified to any target dB with the ``targetlevel`` +option (default: 89 dB). + +When analyzing albums, this plugin can calculates an "album gain" alongside +individual track gains. Album gain normalizes an entire album's loudness while +allowing the dynamics from song to song on the album to remain intact. This is +especially important for classical music albums with large loudness ranges. +Players can choose which gain (track or album) to honor. By default, only +per-track gains are used; to calculate album gain also, set the ``albumgain`` +option to ``yes``. + +If you use a player that does not support ReplayGain specifications, you can +force the volume normalization by applying the gain to the file via the +``apply`` option. This is a lossless and reversible operation with no +transcoding involved. The use of ReplayGain can cause clipping if the average +volume of a song is below the target level. By default, a "prevent clipping" +option named ``noclip`` is enabled to reduce the amount of ReplayGain adjustment +to whatever amount would keep clipping from occurring. + +Manual Analysis +--------------- + +By default, the plugin will analyze all items an albums as they are implemented. +However, you can also manually analyze files that are already in your library. +Use the ``beet replaygain`` command:: + + $ beet replaygain [-a] [QUERY] + +The ``-a`` flag analyzes whole albums instead of individual tracks. Provide a +query (see :doc:`/reference/query`) to indicate which items or albums to +analyze. + +ReplayGain analysis is not fast, so you may want to disable it during import. +Use the ``automatic`` config option to control this:: + + [replaygain] + automatic: no diff --git a/docs/plugins/the.rst b/docs/plugins/the.rst index c137a1216..7fb1903de 100644 --- a/docs/plugins/the.rst +++ b/docs/plugins/the.rst @@ -36,8 +36,6 @@ can add plugin section into config file:: format={0}, {1} # strip instead of moving to the end, default is off strip=no - # do not print warnings, default is off - silent=no # custom regexp patterns, separated by space patterns= diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 71ad8aaaa..cd588ea38 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -51,11 +51,11 @@ right now; this is something we need to work on. Read the configuration file (below). * Also, you can disable the autotagging behavior entirely using ``-A`` - (don't autotag) -- then your music will be imported with its existing + (don't autotag)---then your music will be imported with its existing metadata. * During a long tagging import, it can be useful to keep track of albums - that weren't tagged successfully -- either because they're not in the + that weren't tagged successfully---either because they're not in the MusicBrainz database or because something's wrong with the files. Use the ``-l`` option to specify a filename to log every time you skip and album or import it "as-is" or an album gets skipped as a duplicate. @@ -77,7 +77,11 @@ right now; this is something we need to work on. Read the option to run an *incremental* import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. This is useful if you have an "incoming" directory that you periodically - add things to. (The ``-I`` flag disables incremental imports.) + add things to. + To get this to work correctly, you'll need to use an incremental import *every + time* you run an import on the directory in question---including the first + time, when no subdirectories will be skipped. So consider enabling the + ``import_incremental`` configuration option. * By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer as you every time, @@ -115,6 +119,8 @@ right now; this is something we need to work on. Read the or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` +.. _list-cmd: + list ```` :: @@ -145,6 +151,8 @@ variable expansion. .. _xargs: http://en.wikipedia.org/wiki/Xargs +.. _remove-cmd: + remove `````` :: @@ -158,6 +166,8 @@ You'll be shown a list of the files that will be removed and asked to confirm. By default, this just removes entries from the library database; it doesn't touch the files on disk. To actually delete the files, use ``beet remove -d``. +.. _modify-cmd: + modify `````` :: @@ -191,6 +201,8 @@ destination directory with ``-d`` manually, you can move items matching a query anywhere in your filesystem. The ``-c`` option copies files instead of moving them. As with other commands, the ``-a`` option matches albums instead of items. +.. _update-cmd: + update `````` :: @@ -208,15 +220,20 @@ To perform a "dry run" an update, just use the ``-p`` (for "pretend") flag. This will show you all the proposed changes but won't actually change anything on disk. +.. _stats-cmd: + stats ````` :: - beet stats [QUERY] + beet stats [-e] [QUERY] Show some statistics on your entire library (if you don't provide a :doc:`query `) or the matched items (if you do). +The ``-e`` (``--exact``) option makes the calculation of total file size more +accurate but slower. + fields `````` :: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 7936b4551..eb1954a2d 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -200,18 +200,19 @@ to be changed except on very slow systems. Defaults to 5.0 (5 seconds). list_format_item ~~~~~~~~~~~~~~~~ -Format to use when listing *individual items* with the ``beet list`` -command. Defaults to ``$artist - $album - $title``. The ``-f`` command-line -option overrides this setting. +Format to use when listing *individual items* with the :ref:`list-cmd` +command and other commands that need to print out items. Defaults to +``$artist - $album - $title``. The ``-f`` command-line option overrides +this setting. .. _list_format_album: list_format_album ~~~~~~~~~~~~~~~~~ -Format to use when listing *albums* with the ``beet list`` command. -Defaults to ``$albumartist - $album``. The ``-f`` command-line option -overrides this setting. +Format to use when listing *albums* with :ref:`list-cmd` and other +commands. Defaults to ``$albumartist - $album``. The ``-f`` command-line +option overrides this setting. .. _per_disc_numbering: diff --git a/test/rsrc/test.blb b/test/rsrc/test.blb index 3fe41c8ae..5daf4a2f4 100644 Binary files a/test/rsrc/test.blb and b/test/rsrc/test.blb differ diff --git a/test/test_db.py b/test/test_db.py index fc597b6cd..5df34f45e 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -341,17 +341,6 @@ class DestinationTest(unittest.TestCase): ] self.assertEqual(self.lib.destination(self.i), np('one/three')) - def test_syspath_windows_format(self): - path = ntpath.join('a', 'b', 'c') - outpath = util.syspath(path, ntpath) - self.assertTrue(isinstance(outpath, unicode)) - self.assertTrue(outpath.startswith(u'\\\\?\\')) - - def test_syspath_posix_unchanged(self): - path = posixpath.join('a', 'b', 'c') - outpath = util.syspath(path, posixpath) - self.assertEqual(path, outpath) - def test_sanitize_windows_replaces_trailing_space(self): p = util.sanitize_path(u'one/two /three', ntpath) self.assertFalse(' ' in p) @@ -563,6 +552,36 @@ class DisambiguationTest(unittest.TestCase, PathFormattingMixin): self._setf(u'foo%aunique{albumartist album,albumtype}/$title') self._assert_dest('/base/foo [foo_bar]/the title', self.i1) +class PathConversionTest(unittest.TestCase): + def test_syspath_windows_format(self): + path = ntpath.join('a', 'b', 'c') + outpath = util.syspath(path, ntpath) + self.assertTrue(isinstance(outpath, unicode)) + self.assertTrue(outpath.startswith(u'\\\\?\\')) + + def test_syspath_posix_unchanged(self): + path = posixpath.join('a', 'b', 'c') + outpath = util.syspath(path, posixpath) + self.assertEqual(path, outpath) + + def _windows_bytestring_path(self, path): + old_gfse = sys.getfilesystemencoding + sys.getfilesystemencoding = lambda: 'mbcs' + try: + return util.bytestring_path(path, ntpath) + finally: + sys.getfilesystemencoding = old_gfse + + def test_bytestring_path_windows_encodes_utf8(self): + path = u'caf\xe9' + outpath = self._windows_bytestring_path(path) + self.assertEqual(path, outpath.decode('utf8')) + + def test_bytesting_path_windows_removes_magic_prefix(self): + path = u'\\\\?\\C:\\caf\xe9' + outpath = self._windows_bytestring_path(path) + self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf8')) + class PluginDestinationTest(unittest.TestCase): # Mock the plugins.template_values(item) function. def _template_values(self, item): diff --git a/test/test_ihate.py b/test/test_ihate.py new file mode 100644 index 000000000..3ff6dcd8d --- /dev/null +++ b/test/test_ihate.py @@ -0,0 +1,52 @@ +"""Tests for the 'ihate' plugin""" + +from _common import unittest +from beets.importer import ImportTask +from beets.library import Item +from beetsplug.ihate import IHatePlugin + + +class IHatePluginTest(unittest.TestCase): + + def test_hate(self): + genre_p = [] + artist_p = [] + album_p = [] + white_p = [] + task = ImportTask() + task.cur_artist = u'Test Artist' + task.cur_album = u'Test Album' + task.items = [Item({'genre': 'Test Genre'})] + self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + genre_p = 'some_genre test\sgenre'.split() + self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + genre_p = [] + artist_p = 'bad_artist test\sartist' + self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + artist_p = [] + album_p = 'tribute christmas test'.split() + self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + album_p = [] + white_p = 'goodband test\sartist another_band'.split() + genre_p = 'some_genre test\sgenre'.split() + self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + genre_p = [] + artist_p = 'bad_artist test\sartist' + self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + artist_p = [] + album_p = 'tribute christmas test'.split() + self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, + album_p, white_p)) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 3c1fc1104..5ac8b0d02 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -196,6 +196,15 @@ class MissingAudioDataTest(unittest.TestCase): del self.mf.mgfile.info.bitrate # Not available directly. self.assertEqual(self.mf.bitrate, 0) +class TypeTest(unittest.TestCase): + def setUp(self): + path = os.path.join(_common.RSRC, 'full.mp3') + self.mf = beets.mediafile.MediaFile(path) + + def test_year_integer_in_string(self): + self.mf.year = '2009' + self.assertEqual(self.mf.year, 2009) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_the.py b/test/test_the.py index efdd81d9e..5ed30a858 100644 --- a/test/test_the.py +++ b/test/test_the.py @@ -1,50 +1,59 @@ """Tests for the 'the' plugin""" from _common import unittest -from beetsplug import the +from beetsplug.the import ThePlugin, PATTERN_A, PATTERN_THE, FORMAT class ThePluginTest(unittest.TestCase): - - + def test_unthe_with_default_patterns(self): - self.assertEqual(the.unthe('', the.PATTERN_THE), '') - self.assertEqual(the.unthe('The Something', the.PATTERN_THE), + self.assertEqual(ThePlugin().unthe('', PATTERN_THE), '') + self.assertEqual(ThePlugin().unthe('The Something', PATTERN_THE), 'Something, The') - self.assertEqual(the.unthe('The The', the.PATTERN_THE), 'The, The') - self.assertEqual(the.unthe('The The', the.PATTERN_THE), 'The, The') - self.assertEqual(the.unthe('The The X', the.PATTERN_THE), - u'The X, The') - self.assertEqual(the.unthe('the The', the.PATTERN_THE), 'The, the') - self.assertEqual(the.unthe('Protected The', the.PATTERN_THE), + self.assertEqual(ThePlugin().unthe('The The', PATTERN_THE), + 'The, The') + self.assertEqual(ThePlugin().unthe('The The', PATTERN_THE), + 'The, The') + self.assertEqual(ThePlugin().unthe('The The X', PATTERN_THE), + 'The X, The') + self.assertEqual(ThePlugin().unthe('the The', PATTERN_THE), + 'The, the') + self.assertEqual(ThePlugin().unthe('Protected The', PATTERN_THE), 'Protected The') - self.assertEqual(the.unthe('A Boy', the.PATTERN_A), 'Boy, A') - self.assertEqual(the.unthe('a girl', the.PATTERN_A), 'girl, a') - self.assertEqual(the.unthe('An Apple', the.PATTERN_A), 'Apple, An') - self.assertEqual(the.unthe('An A Thing', the.PATTERN_A), 'A Thing, An') - self.assertEqual(the.unthe('the An Arse', the.PATTERN_A), + self.assertEqual(ThePlugin().unthe('A Boy', PATTERN_A), + 'Boy, A') + self.assertEqual(ThePlugin().unthe('a girl', PATTERN_A), + 'girl, a') + self.assertEqual(ThePlugin().unthe('An Apple', PATTERN_A), + 'Apple, An') + self.assertEqual(ThePlugin().unthe('An A Thing', PATTERN_A), + 'A Thing, An') + self.assertEqual(ThePlugin().unthe('the An Arse', PATTERN_A), 'the An Arse') - self.assertEqual(the.unthe('The Something', the.PATTERN_THE, - strip=True), 'Something') - self.assertEqual(the.unthe('An A', the.PATTERN_A, strip=True), 'A') - + ThePlugin().strip = True + self.assertEqual(ThePlugin().unthe('The Something', PATTERN_THE), + 'Something') + self.assertEqual(ThePlugin().unthe('An A', PATTERN_A), 'A') + ThePlugin().strip = False + def test_template_function_with_defaults(self): - the.the_options['patterns'] = [the.PATTERN_THE, the.PATTERN_A] - the.the_options['format'] = the.FORMAT - self.assertEqual(the.func_the('The The'), 'The, The') - self.assertEqual(the.func_the('An A'), 'A, An') - + ThePlugin().patterns = [PATTERN_THE, PATTERN_A] + ThePlugin().format = FORMAT + self.assertEqual(ThePlugin().the_template_func('The The'), 'The, The') + self.assertEqual(ThePlugin().the_template_func('An A'), 'A, An') + def test_custom_pattern(self): - the.the_options['patterns'] = [ u'^test\s'] - the.the_options['format'] = the.FORMAT - self.assertEqual(the.func_the('test passed'), 'passed, test') - + ThePlugin().patterns = [ u'^test\s'] + ThePlugin().format = FORMAT + self.assertEqual(ThePlugin().the_template_func('test passed'), + 'passed, test') + def test_custom_format(self): - the.the_options['patterns'] = [the.PATTERN_THE, the.PATTERN_A] - the.the_options['format'] = '{1} ({0})' - self.assertEqual(the.func_the('The A'), 'The (A)') - - + ThePlugin().patterns = [PATTERN_THE, PATTERN_A] + ThePlugin().format = '{1} ({0})' + self.assertEqual(ThePlugin().the_template_func('The A'), 'The (A)') + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_ui.py b/test/test_ui.py index 98204771b..eea86adf8 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -47,12 +47,7 @@ class ListTest(unittest.TestCase): self.io.restore() def _run_list(self, query='', album=False, path=False, fmt=None): - if not fmt: - if album: - fmt = commands.DEFAULT_LIST_FORMAT_ALBUM - else: - fmt = commands.DEFAULT_LIST_FORMAT_ITEM - commands.list_items(self.lib, query, album, path, fmt) + commands.list_items(self.lib, query, album, fmt, None) def test_list_outputs_item(self): self._run_list() @@ -69,7 +64,7 @@ class ListTest(unittest.TestCase): self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding)) def test_list_item_path(self): - self._run_list(path=True) + self._run_list(fmt='$path') out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx/yyy') @@ -79,7 +74,7 @@ class ListTest(unittest.TestCase): self.assertGreater(len(out), 0) def test_list_album_path(self): - self._run_list(album=True, path=True) + self._run_list(album=True, fmt='$path') out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx') @@ -119,11 +114,6 @@ class ListTest(unittest.TestCase): self.assertTrue(u'the genre' in out) self.assertTrue(u'the album' not in out) - def test_list_item_path_ignores_format(self): - self._run_list(path=True, fmt='$year - $artist') - out = self.io.getoutput() - self.assertEqual(out.strip(), u'xxx/yyy') - class RemoveTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO() @@ -143,14 +133,14 @@ class RemoveTest(unittest.TestCase): def test_remove_items_no_delete(self): self.io.addinput('y') - commands.remove_items(self.lib, '', False, False) + commands.remove_items(self.lib, '', False, False, None) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertTrue(os.path.exists(self.i.path)) def test_remove_items_with_delete(self): self.io.addinput('y') - commands.remove_items(self.lib, '', False, True) + commands.remove_items(self.lib, '', False, True, None) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) @@ -176,7 +166,7 @@ class ModifyTest(unittest.TestCase): 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) + write, move, album, True, True, None) def test_modify_item_dbdata(self): self._modify(["title=newTitle"]) @@ -334,7 +324,7 @@ class UpdateTest(unittest.TestCase, _common.ExtraAsserts): if reset_mtime: self.i.mtime = 0 self.lib.store(self.i) - commands.update_items(self.lib, query, album, move, True, False) + commands.update_items(self.lib, query, album, move, True, False, None) def test_delete_removes_item(self): self.assertTrue(list(self.lib.items()))