mirror of
https://github.com/beetbox/beets.git
synced 2025-12-16 05:34:47 +01:00
merge fixes and additions from master
This commit is contained in:
commit
815fc83cb4
35 changed files with 1187 additions and 457 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
144
beetsplug/convert.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
142
beetsplug/ihate.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
154
beetsplug/the.py
154
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)
|
||||
|
|
|
|||
|
|
@ -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', ['.']))
|
||||
|
|
|
|||
|
|
@ -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
62
docs/plugins/convert.rst
Normal 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
35
docs/plugins/ihate.rst
Normal 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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
``````
|
||||
::
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
52
test/test_ihate.py
Normal 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')
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
Loading…
Reference in a new issue