merge fixes and additions from master

This commit is contained in:
Adrian Sampson 2012-10-26 19:54:26 -07:00
commit 815fc83cb4
35 changed files with 1187 additions and 457 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

144
beetsplug/convert.py Normal file
View file

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

View file

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

View file

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

142
beetsplug/ihate.py Normal file
View file

@ -0,0 +1,142 @@
# This file is part of beets.
# Copyright 2012, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
# 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', ['.']))

View file

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

62
docs/plugins/convert.rst Normal file
View file

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

35
docs/plugins/ihate.rst Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <query>`) or the matched items (if you do).
The ``-e`` (``--exact``) option makes the calculation of total file size more
accurate but slower.
fields
``````
::

View file

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

Binary file not shown.

View file

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

52
test/test_ihate.py Normal file
View file

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

View file

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

View file

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

View file

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