Merge branch 'master' into thumbnails

This commit is contained in:
Bruno Cauet 2015-03-25 18:17:12 +01:00
commit 265fa962eb
74 changed files with 2121 additions and 671 deletions

View file

@ -42,6 +42,8 @@ def apply_item_metadata(item, track_info):
item.mb_trackid = track_info.track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
if track_info.data_source:
item.data_source = track_info.data_source
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
@ -125,7 +127,8 @@ def apply_metadata(album_info, mapping):
'language',
'country',
'albumstatus',
'albumdisambig'):
'albumdisambig',
'data_source',):
value = getattr(album_info, field)
if value is not None:
item[field] = value

View file

@ -137,6 +137,8 @@ class TrackInfo(object):
- ``artist_sort``: name of the track artist for sorting
- ``disctitle``: name of the individual medium (subtitle)
- ``artist_credit``: Recording-specific artist name
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
- ``data_url``: The data source release URL.
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``

View file

@ -161,6 +161,7 @@ def track_info(recording, index=None, medium=None, medium_index=None,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
data_source='MusicBrainz',
data_url=track_url(recording['id']),
)

View file

@ -43,7 +43,7 @@ pluginpath: []
threaded: yes
timeout: 5.0
per_disc_numbering: no
verbose: no
verbose: 0
terminal_encoding: utf8
original_date: no
id3v23: no
@ -61,8 +61,8 @@ ui:
action_default: turquoise
action: blue
list_format_item: $artist - $album - $title
list_format_album: $albumartist - $album
format_item: $artist - $album - $title
format_album: $albumartist - $album
time_format: '%Y-%m-%d %H:%M:%S'
sort_album: albumartist+ album+

View file

@ -18,13 +18,19 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import re
from operator import attrgetter
from operator import attrgetter, mul
from beets import util
from datetime import datetime, timedelta
class InvalidQueryError(ValueError):
"""Represent any kind of invalid query
class ParsingError(ValueError):
"""Abstract class for any unparseable user-requested album/query
specification.
"""
class InvalidQueryError(ParsingError):
"""Represent any kind of invalid query.
The query should be a unicode string or a list, which will be space-joined.
"""
@ -35,7 +41,7 @@ class InvalidQueryError(ValueError):
super(InvalidQueryError, self).__init__(message)
class InvalidQueryArgumentTypeError(TypeError):
class InvalidQueryArgumentTypeError(ParsingError):
"""Represent a query argument that could not be converted as expected.
It exists to be caught in upper stack levels so a meaningful (i.e. with the
@ -67,6 +73,15 @@ class Query(object):
"""
raise NotImplementedError
def __repr__(self):
return "{0.__class__.__name__}()".format(self)
def __eq__(self, other):
return type(self) == type(other)
def __hash__(self):
return 0
class FieldQuery(Query):
"""An abstract query that searches in a specific field for a
@ -100,6 +115,17 @@ class FieldQuery(Query):
def match(self, item):
return self.value_match(self.pattern, item.get(self.field))
def __repr__(self):
return ("{0.__class__.__name__}({0.field!r}, {0.pattern!r}, "
"{0.fast})".format(self))
def __eq__(self, other):
return super(FieldQuery, self).__eq__(other) and \
self.field == other.field and self.pattern == other.pattern
def __hash__(self):
return hash((self.field, hash(self.pattern)))
class MatchQuery(FieldQuery):
"""A query that looks for exact matches in an item field."""
@ -114,8 +140,7 @@ class MatchQuery(FieldQuery):
class NoneQuery(FieldQuery):
def __init__(self, field, fast=True):
self.field = field
self.fast = fast
super(NoneQuery, self).__init__(field, None, fast)
def col_clause(self):
return self.field + " IS NULL", ()
@ -127,6 +152,9 @@ class NoneQuery(FieldQuery):
except KeyError:
return True
def __repr__(self):
return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)
class StringFieldQuery(FieldQuery):
"""A FieldQuery that converts values to strings before matching
@ -171,8 +199,8 @@ class RegexpQuery(StringFieldQuery):
Raises InvalidQueryError when the pattern is not a valid regular
expression.
"""
def __init__(self, field, pattern, false=True):
super(RegexpQuery, self).__init__(field, pattern, false)
def __init__(self, field, pattern, fast=True):
super(RegexpQuery, self).__init__(field, pattern, fast)
try:
self.pattern = re.compile(self.pattern)
except re.error as exc:
@ -331,6 +359,19 @@ class CollectionQuery(Query):
clause = (' ' + joiner + ' ').join(clause_parts)
return clause, subvals
def __repr__(self):
return "{0.__class__.__name__}({0.subqueries})".format(self)
def __eq__(self, other):
return super(CollectionQuery, self).__eq__(other) and \
self.subqueries == other.subqueries
def __hash__(self):
"""Since subqueries are mutable, this object should not be hashable.
However and for conveniencies purposes, it can be hashed.
"""
return reduce(mul, map(hash, self.subqueries), 1)
class AnyFieldQuery(CollectionQuery):
"""A query that matches if a given FieldQuery subclass matches in
@ -356,6 +397,17 @@ class AnyFieldQuery(CollectionQuery):
return True
return False
def __repr__(self):
return ("{0.__class__.__name__}({0.pattern!r}, {0.fields}, "
"{0.query_class.__name__})".format(self))
def __eq__(self, other):
return super(AnyFieldQuery, self).__eq__(other) and \
self.query_class == other.query_class
def __hash__(self):
return hash((self.pattern, tuple(self.fields), self.query_class))
class MutableCollectionQuery(CollectionQuery):
"""A collection query whose subqueries may be modified after the
@ -590,6 +642,12 @@ class Sort(object):
"""
return False
def __hash__(self):
return 0
def __eq__(self, other):
return type(self) == type(other)
class MultipleSort(Sort):
"""Sort that encapsulates multiple sub-sorts.
@ -651,6 +709,13 @@ class MultipleSort(Sort):
def __repr__(self):
return u'MultipleSort({0})'.format(repr(self.sorts))
def __hash__(self):
return hash(tuple(self.sorts))
def __eq__(self, other):
return super(MultipleSort, self).__eq__(other) and \
self.sorts == other.sorts
class FieldSort(Sort):
"""An abstract sort criterion that orders by a specific field (of
@ -674,6 +739,14 @@ class FieldSort(Sort):
'+' if self.ascending else '-',
)
def __hash__(self):
return hash((self.field, self.ascending))
def __eq__(self, other):
return super(FieldSort, self).__eq__(other) and \
self.field == other.field and \
self.ascending == other.ascending
class FixedFieldSort(FieldSort):
"""Sort object to sort on a fixed field.
@ -695,3 +768,15 @@ class NullSort(Sort):
"""No sorting. Leave results unsorted."""
def sort(items):
return items
def __nonzero__(self):
return self.__bool__()
def __bool__(self):
return False
def __eq__(self, other):
return type(self) == type(other) or other is None
def __hash__(self):
return 0

View file

@ -152,12 +152,14 @@ def sort_from_strings(model_cls, sort_parts):
"""Create a `Sort` from a list of sort criteria (strings).
"""
if not sort_parts:
return query.NullSort()
sort = query.NullSort()
elif len(sort_parts) == 1:
sort = construct_sort_part(model_cls, sort_parts[0])
else:
sort = query.MultipleSort()
for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part))
return sort
return sort
def parse_sorted_query(model_cls, parts, prefixes={},

View file

@ -24,6 +24,7 @@ import unicodedata
import time
import re
from unidecode import unidecode
import platform
from beets import logging
from beets.mediafile import MediaFile, MutagenError, UnreadableFileError
@ -42,30 +43,55 @@ log = logging.getLogger('beets')
# Library-specific query types.
class PathQuery(dbcore.FieldQuery):
"""A query that matches all items under a given path."""
"""A query that matches all items under a given path.
Matching can either be case-insensitive or case-sensitive. By
default, the behavior depends on the OS: case-insensitive on Windows
and case-sensitive otherwise.
"""
escape_re = re.compile(r'[\\_%]')
escape_char = b'\\'
def __init__(self, field, pattern, fast=True):
def __init__(self, field, pattern, fast=True, case_sensitive=None):
"""Create a path query.
`case_sensitive` can be a bool or `None`, indicating that the
behavior should depend on the platform (the default).
"""
super(PathQuery, self).__init__(field, pattern, fast)
# By default, the case sensitivity depends on the platform.
if case_sensitive is None:
case_sensitive = platform.system() != 'Windows'
self.case_sensitive = case_sensitive
# Use a normalized-case pattern for case-insensitive matches.
if not case_sensitive:
pattern = pattern.lower()
# Match the path as a single file.
self.file_path = util.bytestring_path(util.normpath(pattern))
# As a directory (prefix).
self.dir_path = util.bytestring_path(os.path.join(self.file_path, b''))
def match(self, item):
return (item.path == self.file_path) or \
item.path.startswith(self.dir_path)
path = item.path if self.case_sensitive else item.path.lower()
return (path == self.file_path) or path.startswith(self.dir_path)
def col_clause(self):
file_blob = buffer(self.file_path)
if self.case_sensitive:
dir_blob = buffer(self.dir_path)
return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \
(file_blob, len(dir_blob), dir_blob)
escape = lambda m: self.escape_char + m.group(0)
dir_pattern = self.escape_re.sub(escape, self.dir_path)
dir_pattern = buffer(dir_pattern + b'%')
file_blob = buffer(self.file_path)
dir_blob = buffer(dir_pattern + b'%')
return '({0} = ?) || ({0} LIKE ? ESCAPE ?)'.format(self.field), \
(file_blob, dir_pattern, self.escape_char)
(file_blob, dir_blob, self.escape_char)
# Library-specific field types.
@ -244,15 +270,15 @@ class LibModel(dbcore.Model):
def store(self):
super(LibModel, self).store()
plugins.send('database_change', lib=self._db)
plugins.send('database_change', lib=self._db, model=self)
def remove(self):
super(LibModel, self).remove()
plugins.send('database_change', lib=self._db)
plugins.send('database_change', lib=self._db, model=self)
def add(self, lib=None):
super(LibModel, self).add(lib)
plugins.send('database_change', lib=self._db)
plugins.send('database_change', lib=self._db, model=self)
def __format__(self, spec):
if not spec:
@ -393,6 +419,10 @@ class Item(LibModel):
_search_fields = ('artist', 'title', 'comments',
'album', 'albumartist', 'genre')
_types = {
'data_source': types.STRING,
}
_media_fields = set(MediaFile.readable_fields()) \
.intersection(_fields.keys())
"""Set of item fields that are backed by `MediaFile` fields.
@ -414,14 +444,13 @@ class Item(LibModel):
_sorts = {'artist': SmartArtistSort}
_format_config_key = 'list_format_item'
_format_config_key = 'format_item'
@classmethod
def _getters(cls):
getters = plugins.item_field_getters()
getters['singleton'] = lambda i: i.album_id is None
# Filesize is given in bytes
getters['filesize'] = lambda i: os.path.getsize(syspath(i.path))
getters['filesize'] = Item.try_filesize # In bytes.
return getters
@classmethod
@ -605,6 +634,17 @@ class Item(LibModel):
"""
return int(os.path.getmtime(syspath(self.path)))
def try_filesize(self):
"""Get the size of the underlying file in bytes.
If the file is missing, return 0 (and log a warning).
"""
try:
return os.path.getsize(syspath(self.path))
except (OSError, Exception) as exc:
log.warning(u'could not get filesize: {0}', exc)
return 0
# Model methods.
def remove(self, delete=False, with_album=True):
@ -795,7 +835,8 @@ class Album(LibModel):
_search_fields = ('album', 'albumartist', 'genre')
_types = {
'path': PathType(),
'path': PathType(),
'data_source': types.STRING,
}
_sorts = {
@ -836,7 +877,7 @@ class Album(LibModel):
"""List of keys that are set on an album's items.
"""
_format_config_key = 'list_format_album'
_format_config_key = 'format_album'
@classmethod
def _getters(cls):
@ -1081,11 +1122,12 @@ def parse_query_string(s, model_cls):
The string is split into components using shell-like syntax.
"""
assert isinstance(s, unicode), "Query is not unicode: {0!r}".format(s)
# A bug in Python < 2.7.3 prevents correct shlex splitting of
# Unicode strings.
# http://bugs.python.org/issue6988
if isinstance(s, unicode):
s = s.encode('utf8')
s = s.encode('utf8')
try:
parts = [p.decode('utf8') for p in shlex.split(s)]
except ValueError as exc:
@ -1177,21 +1219,29 @@ class Library(dbcore.Database):
model_cls, query, sort
)
@staticmethod
def get_default_album_sort():
"""Get a :class:`Sort` object for albums from the config option.
"""
return dbcore.sort_from_strings(
Album, beets.config['sort_album'].as_str_seq())
@staticmethod
def get_default_item_sort():
"""Get a :class:`Sort` object for items from the config option.
"""
return dbcore.sort_from_strings(
Item, beets.config['sort_item'].as_str_seq())
def albums(self, query=None, sort=None):
"""Get :class:`Album` objects matching the query.
"""
sort = sort or dbcore.sort_from_strings(
Album, beets.config['sort_album'].as_str_seq()
)
return self._fetch(Album, query, sort)
return self._fetch(Album, query, sort or self.get_default_album_sort())
def items(self, query=None, sort=None):
"""Get :class:`Item` objects matching the query.
"""
sort = sort or dbcore.sort_from_strings(
Item, beets.config['sort_item'].as_str_seq()
)
return self._fetch(Item, query, sort)
return self._fetch(Item, query, sort or self.get_default_item_sort())
# Convenience accessors.
@ -1300,11 +1350,11 @@ class DefaultTemplateFunctions(object):
return unidecode(s)
@staticmethod
def tmpl_time(s, format):
def tmpl_time(s, fmt):
"""Format a time value using `strftime`.
"""
cur_fmt = beets.config['time_format'].get(unicode)
return time.strftime(format, time.strptime(s, cur_fmt))
return time.strftime(fmt, time.strptime(s, cur_fmt))
def tmpl_aunique(self, keys=None, disam=None):
"""Generate a string that is guaranteed to be unique among all

View file

@ -1246,18 +1246,31 @@ class DateItemField(MediaField):
class CoverArtField(MediaField):
"""A descriptor that provides access to the *raw image data* for the
first image on a file. This is used for backwards compatibility: the
cover image on a file. This is used for backwards compatibility: the
full `ImageListField` provides richer `Image` objects.
When there are multiple images we try to pick the most likely to be a front
cover.
"""
def __init__(self):
pass
def __get__(self, mediafile, _):
try:
return mediafile.images[0].data
except IndexError:
candidates = mediafile.images
if candidates:
return self.guess_cover_image(candidates).data
else:
return None
@staticmethod
def guess_cover_image(candidates):
if len(candidates) == 1:
return candidates[0]
try:
return next(c for c in candidates if c.type == ImageType.front)
except StopIteration:
return candidates[0]
def __set__(self, mediafile, data):
if data:
mediafile.images = [Image(data=data)]

View file

@ -17,8 +17,8 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
import traceback
import inspect
import traceback
import re
from collections import defaultdict
from functools import wraps
@ -84,10 +84,8 @@ class BeetsPlugin(object):
self._log = log.getChild(self.name)
self._log.setLevel(logging.NOTSET) # Use `beets` logger level.
if beets.config['verbose']:
if not any(isinstance(f, PluginLogFilter)
for f in self._log.filters):
self._log.addFilter(PluginLogFilter(self))
if not any(isinstance(f, PluginLogFilter) for f in self._log.filters):
self._log.addFilter(PluginLogFilter(self))
def commands(self):
"""Should return a list of beets.ui.Subcommand objects for
@ -103,26 +101,39 @@ class BeetsPlugin(object):
`self.import_stages`. Wrapping provides some bookkeeping for the
plugin: specifically, the logging level is adjusted to WARNING.
"""
return [self._set_log_level(logging.WARNING, import_stage)
return [self._set_log_level_and_params(logging.WARNING, import_stage)
for import_stage in self.import_stages]
def _set_log_level(self, log_level, func):
def _set_log_level_and_params(self, base_log_level, func):
"""Wrap `func` to temporarily set this plugin's logger level to
`log_level` (and restore it after the function returns).
`base_log_level` + config options (and restore it to its previous
value after the function returns). Also determines which params may not
be sent for backwards-compatibility.
The level is *not* adjusted when beets is in verbose
mode---i.e., the plugin logger continues to delegate to the base
beets logger.
Note that the log level value may not be NOTSET, e.g. if a plugin
import stage triggers an event that is listened this very same plugin.
"""
argspec = inspect.getargspec(func)
@wraps(func)
def wrapper(*args, **kwargs):
if not beets.config['verbose']:
old_log_level = self._log.level
self._log.setLevel(log_level)
result = func(*args, **kwargs)
if not beets.config['verbose']:
old_log_level = self._log.level
verbosity = beets.config['verbose'].get(int)
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
self._log.setLevel(log_level)
try:
try:
return func(*args, **kwargs)
except TypeError as exc:
if exc.args[0].startswith(func.__name__):
# caused by 'func' and not stuff internal to 'func'
kwargs = dict((arg, val) for arg, val in kwargs.items()
if arg in argspec.args)
return func(*args, **kwargs)
else:
raise
finally:
self._log.setLevel(old_log_level)
return result
return wrapper
def queries(self):
@ -181,36 +192,21 @@ class BeetsPlugin(object):
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
_raw_listeners = None
listeners = None
@classmethod
def register_listener(cls, event, func):
"""Add a function as a listener for the specified event. (An
imperative alternative to the @listen decorator.)
def register_listener(self, event, func):
"""Add a function as a listener for the specified event.
"""
if cls.listeners is None:
wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
cls = self.__class__
if cls.listeners is None or cls._raw_listeners is None:
cls._raw_listeners = defaultdict(list)
cls.listeners = defaultdict(list)
if func not in cls.listeners[event]:
cls.listeners[event].append(func)
@classmethod
def listen(cls, event):
"""Decorator that adds a function as an event handler for the
specified event (as a string). The parameters passed to function
will vary depending on what event occurred.
The function should respond to named parameters.
function(**kwargs) will trap all arguments in a dictionary.
Example:
>>> @MyPlugin.listen("imported")
>>> def importListener(**kwargs):
... pass
"""
def helper(func):
cls.register_listener(event, func)
return func
return helper
if func not in cls._raw_listeners[event]:
cls._raw_listeners[event].append(func)
cls.listeners[event].append(wrapped_func)
template_funcs = None
template_fields = None
@ -459,10 +455,7 @@ def send(event, **arguments):
log.debug(u'Sending event: {0}', event)
results = []
for handler in event_handlers()[event]:
# Don't break legacy plugins if we want to pass more arguments
argspec = inspect.getargspec(handler).args
args = dict((k, v) for k, v in arguments.items() if k in argspec)
result = handler(**args)
result = handler(**arguments)
if result is not None:
results.append(result)
return results

View file

@ -592,6 +592,119 @@ def show_model_changes(new, old=None, fields=None, always=False):
return bool(changes)
class CommonOptionsParser(optparse.OptionParser, object):
"""Offers a simple way to add common formatting options.
Options available include:
- matching albums instead of tracks: add_album_option()
- showing paths instead of items/albums: add_path_option()
- changing the format of displayed items/albums: add_format_option()
The last one can have several behaviors:
- against a special target
- with a certain format
- autodetected target with the album option
Each method is fully documented in the related method.
"""
def __init__(self, *args, **kwargs):
super(CommonOptionsParser, self).__init__(*args, **kwargs)
self._album_flags = False
# this serves both as an indicator that we offer the feature AND allows
# us to check whether it has been specified on the CLI - bypassing the
# fact that arguments may be in any order
def add_album_option(self, flags=('-a', '--album')):
"""Add a -a/--album option to match albums instead of tracks.
If used then the format option can auto-detect whether we're setting
the format for items or albums.
Sets the album property on the options extracted from the CLI.
"""
album = optparse.Option(*flags, action='store_true',
help='match albums instead of tracks')
self.add_option(album)
self._album_flags = set(flags)
def _set_format(self, option, opt_str, value, parser, target=None,
fmt=None, store_true=False):
"""Internal callback that sets the correct format while parsing CLI
arguments.
"""
if store_true:
setattr(parser.values, option.dest, True)
value = fmt or value and unicode(value) or ''
parser.values.format = value
if target:
config[target._format_config_key].set(value)
else:
if self._album_flags:
if parser.values.album:
target = library.Album
else:
# the option is either missing either not parsed yet
if self._album_flags & set(parser.rargs):
target = library.Album
else:
target = library.Item
config[target._format_config_key].set(value)
else:
config[library.Item._format_config_key].set(value)
config[library.Album._format_config_key].set(value)
def add_path_option(self, flags=('-p', '--path')):
"""Add a -p/--path option to display the path instead of the default
format.
By default this affects both items and albums. If add_album_option()
is used then the target will be autodetected.
Sets the format property to u'$path' on the options extracted from the
CLI.
"""
path = optparse.Option(*flags, nargs=0, action='callback',
callback=self._set_format,
callback_kwargs={'fmt': '$path',
'store_true': True},
help='print paths for matched items or albums')
self.add_option(path)
def add_format_option(self, flags=('-f', '--format'), target=None):
"""Add -f/--format option to print some LibModel instances with a
custom format.
`target` is optional and can be one of ``library.Item``, 'item',
``library.Album`` and 'album'.
Several behaviors are available:
- if `target` is given then the format is only applied to that
LibModel
- if the album option is used then the target will be autodetected
- otherwise the format is applied to both items and albums.
Sets the format property on the options extracted from the CLI.
"""
kwargs = {}
if target:
if isinstance(target, basestring):
target = {'item': library.Item,
'album': library.Album}[target]
kwargs['target'] = target
opt = optparse.Option(*flags, action='callback',
callback=self._set_format,
callback_kwargs=kwargs,
help='print with custom format')
self.add_option(opt)
def add_all_common_options(self):
"""Add album, path and format options.
"""
self.add_album_option()
self.add_path_option()
self.add_format_option()
# Subcommand parsing infrastructure.
#
# This is a fairly generic subcommand parser for optparse. It is
@ -600,6 +713,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
# There you will also find a better description of the code and a more
# succinct example program.
class Subcommand(object):
"""A subcommand of a root command-line application that may be
invoked by a SubcommandOptionParser.
@ -609,10 +723,10 @@ class Subcommand(object):
the subcommand; aliases are alternate names. parser is an
OptionParser responsible for parsing the subcommand's options.
help is a short description of the command. If no parser is
given, it defaults to a new, empty OptionParser.
given, it defaults to a new, empty CommonOptionsParser.
"""
self.name = name
self.parser = parser or optparse.OptionParser()
self.parser = parser or CommonOptionsParser()
self.aliases = aliases
self.help = help
self.hide = hide
@ -635,7 +749,7 @@ class Subcommand(object):
root_parser.get_prog_name().decode('utf8'), self.name)
class SubcommandsOptionParser(optparse.OptionParser):
class SubcommandsOptionParser(CommonOptionsParser):
"""A variant of OptionParser that parses subcommands and their
arguments.
"""
@ -653,7 +767,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
kwargs['add_help_option'] = False
# Super constructor.
optparse.OptionParser.__init__(self, *args, **kwargs)
super(SubcommandsOptionParser, self).__init__(*args, **kwargs)
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
@ -670,7 +784,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
# Add the list of subcommands to the help message.
def format_help(self, formatter=None):
# Get the original help message, to which we will append.
out = optparse.OptionParser.format_help(self, formatter)
out = super(SubcommandsOptionParser, self).format_help(formatter)
if formatter is None:
formatter = self.formatter
@ -807,6 +921,7 @@ def _load_plugins(config):
"""
paths = config['pluginpath'].get(confit.StrSeq(split=False))
paths = map(util.normpath, paths)
log.debug('plugin paths: {0}', util.displayable_path(paths))
import beetsplug
beetsplug.__path__ = paths + beetsplug.__path__
@ -858,7 +973,7 @@ def _configure(options):
config.set_args(options)
# Configure the logger.
if config['verbose'].get(bool):
if config['verbose'].get(int):
log.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
@ -871,6 +986,17 @@ def _configure(options):
u'See documentation for more info.')
config['ui']['color'].set(config['color'].get(bool))
# Compatibility from list_format_{item,album} to format_{item,album}
for elem in ('item', 'album'):
old_key = 'list_format_{0}'.format(elem)
if config[old_key].exists():
new_key = 'format_{0}'.format(elem)
log.warning('Warning: configuration uses "{0}" which is deprecated'
' in favor of "{1}" now that it affects all commands. '
'See changelog & documentation.'.format(old_key,
new_key))
config[new_key].set(config[old_key])
config_path = config.user_config_path()
if os.path.isfile(config_path):
log.debug(u'user configuration: {0}',
@ -913,11 +1039,13 @@ def _raw_main(args, lib=None):
handling.
"""
parser = SubcommandsOptionParser()
parser.add_format_option(flags=('--format-item',), target=library.Item)
parser.add_format_option(flags=('--format-album',), target=library.Album)
parser.add_option('-l', '--library', dest='library',
help='library database file to use')
parser.add_option('-d', '--directory', dest='directory',
help="destination music directory")
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
parser.add_option('-v', '--verbose', dest='verbose', action='count',
help='print debugging information')
parser.add_option('-c', '--config', dest='config',
help='path to configuration file')

View file

@ -20,9 +20,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
import platform
import re
import shlex
import beets
from beets import ui
@ -96,11 +94,15 @@ def fields_func(lib, opts, args):
_print_rows(plugin_fields)
print("Item fields:")
_print_rows(library.Item._fields.keys())
_print_rows(library.Item._fields.keys() +
library.Item._getters().keys() +
library.Item._types.keys())
_show_plugin_fields(False)
print("\nAlbum fields:")
_print_rows(library.Album._fields.keys())
_print_rows(library.Album._fields.keys() +
library.Album._getters().keys() +
library.Album._types.keys())
_show_plugin_fields(True)
@ -424,8 +426,6 @@ def summarize_items(items, singleton):
this is an album or single-item import (if the latter, them `items`
should only have one element).
"""
assert items, "summarizing zero items"
summary_parts = []
if not singleton:
summary_parts.append("{0} items".format(len(items)))
@ -437,16 +437,18 @@ def summarize_items(items, singleton):
# A single format.
summary_parts.append(items[0].format)
else:
# Enumerate all the formats.
for format, count in format_counts.iteritems():
summary_parts.append('{0} {1}'.format(format, count))
# Enumerate all the formats by decreasing frequencies:
for fmt, count in sorted(format_counts.items(),
key=lambda (f, c): (-c, f)):
summary_parts.append('{0} {1}'.format(fmt, count))
average_bitrate = sum([item.bitrate for item in items]) / len(items)
total_duration = sum([item.length for item in items])
total_filesize = sum([item.filesize for item in items])
summary_parts.append('{0}kbps'.format(int(average_bitrate / 1000)))
summary_parts.append(ui.human_seconds_short(total_duration))
summary_parts.append(ui.human_bytes(total_filesize))
if items:
average_bitrate = sum([item.bitrate for item in items]) / len(items)
total_duration = sum([item.length for item in items])
total_filesize = sum([item.filesize for item in items])
summary_parts.append('{0}kbps'.format(int(average_bitrate / 1000)))
summary_parts.append(ui.human_seconds_short(total_duration))
summary_parts.append(ui.human_bytes(total_filesize))
return ', '.join(summary_parts)
@ -776,6 +778,16 @@ class TerminalImportSession(importer.ImportSession):
log.warn(u"This {0} is already in the library!",
("album" if task.is_album else "item"))
# skip empty albums (coming from a previous failed import session)
if task.is_album:
real_duplicates = [dup for dup in found_duplicates if dup.items()]
if not real_duplicates:
log.info("All duplicates are empty, we ignore them")
task.should_remove_duplicates = True
return
else:
real_duplicates = found_duplicates
if config['import']['quiet']:
# In quiet mode, don't prompt -- just skip.
log.info(u'Skipping.')
@ -783,11 +795,17 @@ class TerminalImportSession(importer.ImportSession):
else:
# Print some detail about the existing and new items so the
# user can make an informed decision.
for duplicate in found_duplicates:
for duplicate in real_duplicates:
print("Old: " + summarize_items(
list(duplicate.items()) if task.is_album else [duplicate],
not task.is_album,
))
if real_duplicates != found_duplicates: # there's empty albums
count = len(found_duplicates) - len(real_duplicates)
print("Old: {0} empty album{1}".format(
count, "s" if count > 1 else ""))
print("New: " + summarize_items(
task.imported_items(),
not task.is_album,
@ -955,7 +973,7 @@ default_commands.append(import_cmd)
# list: Query and show library contents.
def list_items(lib, query, album, 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.
"""
@ -968,23 +986,11 @@ def list_items(lib, query, album, fmt):
def list_func(lib, opts, args):
fmt = '$path' if opts.path else opts.format
list_items(lib, decargs(args), opts.album, fmt)
list_items(lib, decargs(args), opts.album)
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
list_cmd.parser.add_option(
'-a', '--album', action='store_true',
help='show matching albums instead of tracks'
)
list_cmd.parser.add_option(
'-p', '--path', action='store_true',
help='print paths for matched items or albums'
)
list_cmd.parser.add_option(
'-f', '--format', action='store',
help='print with custom format', default=''
)
list_cmd.parser.add_all_common_options()
list_cmd.func = list_func
default_commands.append(list_cmd)
@ -1085,10 +1091,8 @@ def update_func(lib, opts, args):
update_cmd = ui.Subcommand(
'update', help='update the library', aliases=('upd', 'up',)
)
update_cmd.parser.add_option(
'-a', '--album', action='store_true',
help='match albums instead of tracks'
)
update_cmd.parser.add_album_option()
update_cmd.parser.add_format_option()
update_cmd.parser.add_option(
'-M', '--nomove', action='store_false', default=True, dest='move',
help="don't move files in library"
@ -1097,10 +1101,6 @@ 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=''
)
update_cmd.func = update_func
default_commands.append(update_cmd)
@ -1149,10 +1149,7 @@ remove_cmd.parser.add_option(
"-d", "--delete", action="store_true",
help="also remove files from disk"
)
remove_cmd.parser.add_option(
'-a', '--album', action='store_true',
help='match albums instead of tracks'
)
remove_cmd.parser.add_album_option()
remove_cmd.func = remove_func
default_commands.append(remove_cmd)
@ -1346,18 +1343,12 @@ modify_cmd.parser.add_option(
'-W', '--nowrite', action='store_false', dest='write',
help="don't write metadata (opposite of -w)"
)
modify_cmd.parser.add_option(
'-a', '--album', action='store_true',
help='modify whole albums instead of tracks'
)
modify_cmd.parser.add_album_option()
modify_cmd.parser.add_format_option(target='item')
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=''
)
modify_cmd.func = modify_func
default_commands.append(modify_cmd)
@ -1403,10 +1394,7 @@ move_cmd.parser.add_option(
'-c', '--copy', default=False, action='store_true',
help='copy instead of moving'
)
move_cmd.parser.add_option(
'-a', '--album', default=False, action='store_true',
help='match whole albums instead of tracks'
)
move_cmd.parser.add_album_option()
move_cmd.func = move_func
default_commands.append(move_cmd)
@ -1495,30 +1483,14 @@ def config_edit():
"""
path = config.user_config_path()
if 'EDITOR' in os.environ:
editor = os.environ['EDITOR'].encode('utf8')
try:
editor = [e.decode('utf8') for e in shlex.split(editor)]
except ValueError: # Malformed shell tokens.
editor = [editor]
args = editor + [path]
args.insert(1, args[0])
elif platform.system() == 'Darwin':
args = ['open', 'open', '-n', path]
elif platform.system() == 'Windows':
# On windows we can execute arbitrary files. The os will
# take care of starting an appropriate application
args = [path, path]
else:
# Assume Unix
args = ['xdg-open', 'xdg-open', path]
editor = os.environ.get('EDITOR')
try:
os.execlp(*args)
except OSError:
raise ui.UserError("Could not edit configuration. Please "
"set the EDITOR environment variable.")
util.interactive_open(path, editor)
except OSError as exc:
message = "Could not edit configuration: {0}".format(exc)
if not editor:
message += ". Please set the EDITOR environment variable"
raise ui.UserError(message)
config_cmd = ui.Subcommand('config',
help='show or edit the user configuration')

View file

@ -26,6 +26,7 @@ from collections import defaultdict
import traceback
import subprocess
import platform
import shlex
MAX_FILENAME_LENGTH = 200
@ -623,7 +624,7 @@ def cpu_count():
num = 0
elif sys.platform == b'darwin':
try:
num = int(command_output(['sysctl', '-n', 'hw.ncpu']))
num = int(command_output([b'sysctl', b'-n', b'hw.ncpu']))
except ValueError:
num = 0
else:
@ -640,8 +641,8 @@ def cpu_count():
def command_output(cmd, shell=False):
"""Runs the command and returns its output after it has exited.
``cmd`` is a list of arguments starting with the command names. If
``shell`` is true, ``cmd`` is assumed to be a string and passed to a
``cmd`` is a list of byte string arguments starting with the command names.
If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
shell to execute.
If the process exits with a non-zero return code
@ -663,7 +664,7 @@ def command_output(cmd, shell=False):
if proc.returncode:
raise subprocess.CalledProcessError(
returncode=proc.returncode,
cmd=' '.join(cmd),
cmd=b' '.join(cmd),
)
return stdout
@ -683,3 +684,40 @@ def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
return min(res[9], limit)
else:
return limit
def open_anything():
"""Return the system command that dispatches execution to the correct
program.
"""
sys_name = platform.system()
if sys_name == 'Darwin':
base_cmd = 'open'
elif sys_name == 'Windows':
base_cmd = 'start'
else: # Assume Unix
base_cmd = 'xdg-open'
return base_cmd
def interactive_open(target, command=None):
"""Open `target` file with `command` or, in not available, ask the OS to
deal with it.
The executed program will have stdin, stdout and stderr.
OSError may be raised, it is left to the caller to catch them.
"""
if command:
command = command.encode('utf8')
try:
command = [c.decode('utf8')
for c in shlex.split(command)]
except ValueError: # Malformed shell tokens.
command = [command]
command.insert(0, command[0]) # for argv[0]
else:
base_cmd = open_anything()
command = [base_cmd, base_cmd]
command.append(target)
return os.execlp(*command)

View file

@ -91,8 +91,8 @@ def im_resize(maxwidth, path_in, path_out=None):
# compatibility.
try:
util.command_output([
'convert', util.syspath(path_in),
'-resize', '{0}x^>'.format(maxwidth), path_out
b'convert', util.syspath(path_in),
b'-resize', b'{0}x^>'.format(maxwidth), path_out
])
except subprocess.CalledProcessError:
log.warn(u'artresizer: IM convert failed for {0}',
@ -227,7 +227,7 @@ def has_IM():
"""Return Image Magick version or None if it is unavailable
Try invoking ImageMagick's "convert"."""
try:
out = util.command_output(['identify', '--version'])
out = util.command_output([b'identify', b'--version'])
if 'imagemagick' in out.lower():
pattern = r".+ (\d+)\.(\d+)\.(\d+).*"

View file

@ -602,11 +602,11 @@ class Dumper(yaml.SafeDumper):
for item_key, item_value in mapping:
node_key = self.represent_data(item_key)
node_value = self.represent_data(item_value)
if not (isinstance(node_key, yaml.ScalarNode)
and not node_key.style):
if not (isinstance(node_key, yaml.ScalarNode) and
not node_key.style):
best_style = False
if not (isinstance(node_value, yaml.ScalarNode)
and not node_value.style):
if not (isinstance(node_value, yaml.ScalarNode) and
not node_value.style):
best_style = False
value.append((node_key, node_value))
if flow_style is None:

View file

@ -309,8 +309,8 @@ class Parser(object):
# A non-special character. Skip to the next special
# character, treating the interstice as literal text.
next_pos = (
self.special_char_re.search(self.string[self.pos:]).start()
+ self.pos
self.special_char_re.search(
self.string[self.pos:]).start() + self.pos
)
text_parts.append(self.string[self.pos:next_pos])
self.pos = next_pos

View file

@ -136,6 +136,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
if self.config['auto']:
self.register_listener('import_task_start', self.fingerprint_task)
self.register_listener('import_task_apply', apply_acoustid_metadata)
def fingerprint_task(self, task, session):
return fingerprint_task(self._log, task, session)
@ -211,7 +212,6 @@ def fingerprint_task(log, task, session):
acoustid_match(log, item.path)
@AcoustidPlugin.listen('import_task_apply')
def apply_acoustid_metadata(task, session):
"""Apply Acoustid metadata (fingerprint and ID) to the task's items.
"""

View file

@ -49,25 +49,25 @@ def replace_ext(path, ext):
return os.path.splitext(path)[0] + b'.' + ext
def get_format(format=None):
def get_format(fmt=None):
"""Return the command tempate and the extension from the config.
"""
if not format:
format = config['convert']['format'].get(unicode).lower()
format = ALIASES.get(format, format)
if not fmt:
fmt = config['convert']['format'].get(unicode).lower()
fmt = ALIASES.get(fmt, fmt)
try:
format_info = config['convert']['formats'][format].get(dict)
format_info = config['convert']['formats'][fmt].get(dict)
command = format_info['command']
extension = format_info['extension']
except KeyError:
raise ui.UserError(
u'convert: format {0} needs "command" and "extension" fields'
.format(format)
.format(fmt)
)
except ConfigTypeError:
command = config['convert']['formats'][format].get(bytes)
extension = format
command = config['convert']['formats'][fmt].get(bytes)
extension = fmt
# Convenience and backwards-compatibility shortcuts.
keys = config['convert'].keys()
@ -84,7 +84,7 @@ def get_format(format=None):
return (command.encode('utf8'), extension.encode('utf8'))
def should_transcode(item, format):
def should_transcode(item, fmt):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
@ -92,7 +92,7 @@ def should_transcode(item, format):
not (item.format.lower() in LOSSLESS_FORMATS):
return False
maxbr = config['convert']['max_bitrate'].get(int)
return format.lower() != item.format.lower() or \
return fmt.lower() != item.format.lower() or \
item.bitrate >= 1000 * maxbr
@ -139,8 +139,6 @@ class ConvertPlugin(BeetsPlugin):
cmd = ui.Subcommand('convert', help='convert to external location')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show actions but do nothing')
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 available processors')
@ -148,11 +146,12 @@ class ConvertPlugin(BeetsPlugin):
dest='keep_new', help='keep only the converted \
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
help='set the destination directory')
help='set the target format of the tracks')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help='set the destination directory')
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
help='do not ask for confirmation')
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@ -209,9 +208,9 @@ class ConvertPlugin(BeetsPlugin):
self._log.info(u'Finished encoding {0}',
util.displayable_path(source))
def convert_item(self, dest_dir, keep_new, path_formats, format,
def convert_item(self, dest_dir, keep_new, path_formats, fmt,
pretend=False):
command, ext = get_format(format)
command, ext = get_format(fmt)
item, original, converted = None, None, None
while True:
item = yield (item, original, converted)
@ -224,11 +223,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
if should_transcode(item, format):
if should_transcode(item, fmt):
converted = replace_ext(converted, ext)
else:
original = item.path
if should_transcode(item, format):
if should_transcode(item, fmt):
dest = replace_ext(dest, ext)
converted = dest
@ -254,7 +253,7 @@ class ConvertPlugin(BeetsPlugin):
util.displayable_path(original))
util.move(item.path, original)
if should_transcode(item, format):
if should_transcode(item, fmt):
try:
self.encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
@ -359,7 +358,7 @@ class ConvertPlugin(BeetsPlugin):
self.config['pretend'].get(bool)
if not pretend:
ui.commands.list_items(lib, ui.decargs(args), opts.album, '')
ui.commands.list_items(lib, ui.decargs(args), opts.album)
if not (opts.yes or ui.input_yn("Convert? (Y/n)")):
return
@ -386,8 +385,8 @@ class ConvertPlugin(BeetsPlugin):
"""Transcode a file automatically after it is imported into the
library.
"""
format = self.config['format'].get(unicode).lower()
if should_transcode(item, format):
fmt = self.config['format'].get(unicode).lower()
if should_transcode(item, fmt):
command, ext = get_format()
fd, dest = tempfile.mkstemp('.' + ext)
os.close(fd)

54
beetsplug/cue.py Normal file
View file

@ -0,0 +1,54 @@
# Copyright 2015 Bruno Cauet
# Split an album-file in tracks thanks a cue file
import subprocess
from os import path
from glob import glob
from beets.util import command_output, displayable_path
from beets.plugins import BeetsPlugin
from beets.autotag import TrackInfo
class CuePlugin(BeetsPlugin):
def __init__(self):
super(CuePlugin, self).__init__()
# this does not seem supported by shnsplit
self.config.add({
'keep_before': .1,
'keep_after': .9,
})
# self.register_listener('import_task_start', self.look_for_cues)
def candidates(self, items, artist, album, va_likely):
import pdb
pdb.set_trace()
def item_candidates(self, item, artist, album):
dir = path.dirname(item.path)
cues = glob.glob(path.join(dir, "*.cue"))
if not cues:
return
if len(cues) > 1:
self._log.info(u"Found multiple cue files doing nothing: {0}",
map(displayable_path, cues))
cue_file = cues[0]
self._log.info("Found {} for {}", displayable_path(cue_file), item)
try:
# careful: will ask for input in case of conflicts
command_output(['shnsplit', '-f', cue_file, item.path])
except (subprocess.CalledProcessError, OSError):
self._log.exception(u'shnsplit execution failed')
return
tracks = glob(path.join(dir, "*.wav"))
self._log.info("Generated {0} tracks", len(tracks))
for t in tracks:
title = "dunno lol"
track_id = "wtf"
index = int(path.basename(t)[len("split-track"):-len(".wav")])
yield TrackInfo(title, track_id, index=index, artist=artist)
# generate TrackInfo instances

View file

@ -32,6 +32,7 @@ import time
import json
import socket
import httplib
import os
# Silence spurious INFO log lines generated by urllib3.
@ -58,7 +59,7 @@ class DiscogsPlugin(BeetsPlugin):
self.discogs_client = None
self.register_listener('import_begin', self.setup)
def setup(self):
def setup(self, session=None):
"""Create the `discogs_client` field. Authenticate if necessary.
"""
c_key = self.config['apikey'].get(unicode)
@ -78,6 +79,12 @@ class DiscogsPlugin(BeetsPlugin):
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
token, secret)
def reset_auth(self):
"""Delete toke file & redo the auth steps.
"""
os.remove(self._tokenfile())
self.setup()
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
@ -130,7 +137,11 @@ class DiscogsPlugin(BeetsPlugin):
return self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
return []
if e.status_code == 401:
self.reset_auth()
return self.candidates(items, artist, album, va_likely)
else:
return []
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
return []
@ -156,8 +167,11 @@ class DiscogsPlugin(BeetsPlugin):
try:
getattr(result, 'title')
except DiscogsAPIError as e:
if e.message != '404 Not Found':
if e.status_code != 404:
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
if e.status_code == 401:
self.reset_auth()
return self.album_for_id(album_id)
return None
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
@ -178,7 +192,13 @@ class DiscogsPlugin(BeetsPlugin):
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
releases = self.discogs_client.search(query, type='release').page(1)
try:
releases = self.discogs_client.search(query,
type='release').page(1)
except CONNECTION_ERRORS as exc:
self._log.debug("Communication error while searching for {0!r}: "
"{1}".format(query, exc))
return []
return [self.get_album_info(release) for release in releases[:5]]
def get_album_info(self, result):

View file

@ -125,17 +125,6 @@ class DuplicatesPlugin(BeetsPlugin):
self._command = Subcommand('duplicates',
help=__doc__,
aliases=['dup'])
self._command.parser.add_option('-f', '--format', dest='format',
action='store', type='string',
help='print with custom format',
metavar='FMT', default='')
self._command.parser.add_option('-a', '--album', dest='album',
action='store_true',
help='show duplicate albums instead of'
' tracks')
self._command.parser.add_option('-c', '--count', dest='count',
action='store_true',
help='show duplicate counts')
@ -168,15 +157,11 @@ class DuplicatesPlugin(BeetsPlugin):
action='store', metavar='DEST',
help='copy items to dest')
self._command.parser.add_option('-p', '--path', dest='path',
action='store_true',
help='print paths for matched items or'
' albums')
self._command.parser.add_option('-t', '--tag', dest='tag',
action='store',
help='tag matched items with \'k=v\''
' attribute')
self._command.parser.add_all_common_options()
def commands(self):

View file

@ -486,10 +486,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
'-t', '--threshold', dest='threshold', action='store',
type='float', default=0.15, help='Set difference threshold'
)
sim_cmd.parser.add_option(
'-f', '--format', action='store', default='${difference}: ${path}',
help='print with custom format'
)
sim_cmd.parser.add_format_option()
def sim_func(lib, opts, args):
self.config.set_args(opts)

View file

@ -26,7 +26,7 @@ from beets.plugins import BeetsPlugin
from beets import mediafile
from beets import ui
from beets.ui import decargs
from beets.util import syspath, normpath, displayable_path
from beets.util import syspath, normpath, displayable_path, bytestring_path
from beets.util.artresizer import ArtResizer
from beets import config
@ -101,7 +101,8 @@ class EmbedCoverArtPlugin(BeetsPlugin):
self.extract_first(normpath(opts.outpath),
lib.items(decargs(args)))
else:
filename = opts.filename or config['art_filename'].get()
filename = bytestring_path(opts.filename or
config['art_filename'].get())
if os.path.dirname(filename) != '':
self._log.error(u"Only specify a name rather than a path "
u"for -n")
@ -195,13 +196,13 @@ class EmbedCoverArtPlugin(BeetsPlugin):
# Converting images to grayscale tends to minimize the weight
# of colors in the diff score.
convert_proc = subprocess.Popen(
['convert', syspath(imagepath), syspath(art),
'-colorspace', 'gray', 'MIFF:-'],
[b'convert', syspath(imagepath), syspath(art),
b'-colorspace', b'gray', b'MIFF:-'],
stdout=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
['compare', '-metric', 'PHASH', '-', 'null:'],
[b'compare', b'-metric', b'PHASH', b'-', b'null:'],
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@ -265,7 +266,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
self._log.warning(u'Unknown image type in {0}.',
displayable_path(item.path))
return
outpath += '.' + ext
outpath += b'.' + ext
self._log.info(u'Extracting album art from: {0} to: {1}',
item, displayable_path(outpath))

View file

@ -142,6 +142,8 @@ class ITunesStore(ArtSource):
def get(self, album):
"""Return art URL from iTunes Store given an album title.
"""
if not (album.albumartist and album.album):
return
search_string = (album.albumartist + ' ' + album.album).encode('utf-8')
try:
# Isolate bugs in the iTunes library while searching.

View file

@ -140,10 +140,11 @@ def apply_matches(d):
# Plugin structure and hook into import process.
class FromFilenamePlugin(plugins.BeetsPlugin):
pass
def __init__(self):
super(FromFilenamePlugin, self).__init__()
self.register_listener('import_task_start', filename_task)
@FromFilenamePlugin.listen('import_task_start')
def filename_task(task, session):
"""Examine each item in the task to see if we can extract a title
from the filename. Try to match all filenames to a number of

View file

@ -145,7 +145,7 @@ class InfoPlugin(BeetsPlugin):
for data_emitter in data_collector(lib, ui.decargs(args)):
try:
data = data_emitter()
except mediafile.UnreadableFileError as ex:
except (mediafile.UnreadableFileError, IOError) as ex:
self._log.error(u'cannot read file: {0}', ex)
continue

View file

@ -26,6 +26,7 @@ https://gist.github.com/1241307
import pylast
import os
import yaml
import traceback
from beets import plugins
from beets import ui
@ -391,17 +392,22 @@ class LastGenrePlugin(plugins.BeetsPlugin):
If `min_weight` is specified, tags are filtered by weight.
"""
# Work around an inconsistency in pylast where
# Album.get_top_tags() does not return TopItem instances.
# https://code.google.com/p/pylast/issues/detail?id=85
if isinstance(obj, pylast.Album):
obj = super(pylast.Album, obj)
try:
# Work around an inconsistency in pylast where
# Album.get_top_tags() does not return TopItem instances.
# https://code.google.com/p/pylast/issues/detail?id=85
if isinstance(obj, pylast.Album):
res = super(pylast.Album, obj).get_top_tags()
else:
res = obj.get_top_tags()
res = obj.get_top_tags()
except PYLAST_EXCEPTIONS as exc:
self._log.debug(u'last.fm error: {0}', exc)
return []
except Exception as exc:
# Isolate bugs in pylast.
self._log.debug(traceback.format_exc())
self._log.error('error in pylast library: {0}', exc)
return []
# Filter by weight (optionally).
if min_weight:

View file

@ -51,8 +51,8 @@ class LastImportPlugin(plugins.BeetsPlugin):
def import_lastfm(lib, log):
user = config['lastfm']['user']
per_page = config['lastimport']['per_page']
user = config['lastfm']['user'].get(unicode)
per_page = config['lastimport']['per_page'].get(int)
if not user:
raise ui.UserError('You must specify a user name for lastimport')

View file

@ -52,8 +52,7 @@ class MBSyncPlugin(BeetsPlugin):
cmd.parser.add_option('-W', '--nowrite', action='store_false',
default=config['import']['write'], dest='write',
help="don't write updated metadata to files")
cmd.parser.add_option('-f', '--format', action='store', default='',
help='print with custom format')
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
@ -64,17 +63,16 @@ class MBSyncPlugin(BeetsPlugin):
pretend = opts.pretend
write = opts.write
query = ui.decargs(args)
fmt = opts.format
self.singletons(lib, query, move, pretend, write, fmt)
self.albums(lib, query, move, pretend, write, fmt)
self.singletons(lib, query, move, pretend, write)
self.albums(lib, query, move, pretend, write)
def singletons(self, lib, query, move, pretend, write, fmt):
def singletons(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + ['singleton:true']):
item_formatted = format(item, fmt)
item_formatted = format(item)
if not item.mb_trackid:
self._log.info(u'Skipping singleton with no mb_trackid: {0}',
item_formatted)
@ -93,13 +91,13 @@ class MBSyncPlugin(BeetsPlugin):
autotag.apply_item_metadata(item, track_info)
apply_item_changes(lib, item, move, pretend, write)
def albums(self, lib, query, move, pretend, write, fmt):
def albums(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for albums matched by
query and their items.
"""
# Process matching albums.
for a in lib.albums(query):
album_formatted = format(a, fmt)
album_formatted = format(a)
if not a.mb_albumid:
self._log.info(u'Skipping album with no mb_albumid: {0}',
album_formatted)

View file

@ -94,19 +94,13 @@ class MissingPlugin(BeetsPlugin):
self._command = Subcommand('missing',
help=__doc__,
aliases=['miss'])
self._command.parser.add_option('-f', '--format', dest='format',
action='store', type='string',
help='print with custom FORMAT',
metavar='FORMAT', default='')
self._command.parser.add_option('-c', '--count', dest='count',
action='store_true',
help='count missing tracks per album')
self._command.parser.add_option('-t', '--total', dest='total',
action='store_true',
help='count total of missing tracks')
self._command.parser.add_format_option()
def commands(self):
def _miss(lib, opts, args):

View file

@ -166,8 +166,8 @@ class MPDStats(object):
else:
rolling = (rating + (1.0 - rating) / 2.0)
stable = (play_count + 1.0) / (play_count + skip_count + 2.0)
return (self.rating_mix * stable
+ (1.0 - self.rating_mix) * rolling)
return (self.rating_mix * stable +
(1.0 - self.rating_mix) * rolling)
def get_item(self, path):
"""Return the beets item related to path.

View file

@ -80,7 +80,7 @@ class MPDUpdatePlugin(BeetsPlugin):
self.register_listener('database_change', self.db_change)
def db_change(self, lib):
def db_change(self, lib, model):
self.register_listener('cli_exit', self.update)
def update(self, lib):

View file

@ -6,10 +6,12 @@ like the following in your config.yaml to configure:
permissions:
file: 644
dir: 755
"""
import os
from beets import config, util
from beets.plugins import BeetsPlugin
from beets.util import ancestry
def convert_perm(perm):
@ -28,38 +30,52 @@ def check_permissions(path, permission):
return oct(os.stat(path).st_mode & 0o777) == oct(permission)
def dirs_in_library(library, item):
"""Creates a list of ancestor directories in the beets library path.
"""
return [ancestor
for ancestor in ancestry(item)
if ancestor.startswith(library)][1:]
class Permissions(BeetsPlugin):
def __init__(self):
super(Permissions, self).__init__()
# Adding defaults.
self.config.add({
u'file': 644
u'file': 644,
u'dir': 755
})
self.register_listener('item_imported', permissions)
self.register_listener('album_imported', permissions)
@Permissions.listen('item_imported')
@Permissions.listen('album_imported')
def permissions(lib, item=None, album=None):
"""Running the permission fixer.
"""
# Getting the config.
file_perm = config['permissions']['file'].get()
dir_perm = config['permissions']['dir'].get()
# Converts file permissions to oct.
# Converts permissions to oct.
file_perm = convert_perm(file_perm)
dir_perm = convert_perm(dir_perm)
# Create chmod_queue.
chmod_queue = []
file_chmod_queue = []
if item:
chmod_queue.append(item.path)
file_chmod_queue.append(item.path)
elif album:
for album_item in album.items():
chmod_queue.append(album_item.path)
file_chmod_queue.append(album_item.path)
# Setting permissions for every path in the queue.
for path in chmod_queue:
# Changing permissions on the destination path.
# A set of directories to change permissions for.
dir_chmod_queue = set()
for path in file_chmod_queue:
# Changing permissions on the destination file.
os.chmod(util.bytestring_path(path), file_perm)
# Checks if the destination path has the permissions configured.
@ -67,3 +83,19 @@ def permissions(lib, item=None, album=None):
message = 'There was a problem setting permission on {}'.format(
path)
print(message)
# Adding directories to the directory chmod queue.
dir_chmod_queue.update(
dirs_in_library(config['directory'].get(),
path))
# Change permissions for the directories.
for path in dir_chmod_queue:
# Chaning permissions on the destination directory.
os.chmod(util.bytestring_path(path), dir_perm)
# Checks if the destination path has the permissions configured.
if not check_permissions(util.bytestring_path(path), dir_perm):
message = 'There was a problem setting permission on {}'.format(
path)
print(message)

View file

@ -17,104 +17,15 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from functools import partial
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets import config
from beets import ui
from beets import util
from os.path import relpath
import platform
import shlex
from tempfile import NamedTemporaryFile
def play_music(lib, opts, args, log):
"""Execute query, create temporary playlist and execute player
command passing that playlist.
"""
command_str = config['play']['command'].get()
use_folders = config['play']['use_folders'].get(bool)
relative_to = config['play']['relative_to'].get()
if relative_to:
relative_to = util.normpath(relative_to)
if command_str:
command = shlex.split(command_str)
else:
# If a command isn't set, then let the OS decide how to open the
# playlist.
sys_name = platform.system()
if sys_name == 'Darwin':
command = ['open']
elif sys_name == 'Windows':
command = ['start']
else:
# If not Mac or Windows, then assume Unixy.
command = ['xdg-open']
# Preform search by album and add folders rather then tracks to playlist.
if opts.album:
selection = lib.albums(ui.decargs(args))
paths = []
for album in selection:
if use_folders:
paths.append(album.item_dir())
else:
# TODO use core's sorting functionality
paths.extend([item.path for item in sorted(
album.items(), key=lambda item: (item.disc, item.track))])
item_type = 'album'
# Preform item query and add tracks to playlist.
else:
selection = lib.items(ui.decargs(args))
paths = [item.path for item in selection]
item_type = 'track'
item_type += 's' if len(selection) > 1 else ''
if not selection:
ui.print_(ui.colorize('text_warning',
'No {0} to play.'.format(item_type)))
return
# Warn user before playing any huge playlists.
if len(selection) > 100:
ui.print_(ui.colorize(
'text_warning',
'You are about to queue {0} {1}.'.format(len(selection), item_type)
))
if ui.input_options(('Continue', 'Abort')) == 'a':
return
# Create temporary m3u file to hold our playlist.
m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False)
for item in paths:
if relative_to:
m3u.write(relpath(item, relative_to) + '\n')
else:
m3u.write(item + '\n')
m3u.close()
command.append(m3u.name)
# Invoke the command and log the output.
output = util.command_output(command)
if output:
log.debug(u'Output of {0}: {1}',
util.displayable_path(command[0]),
output.decode('utf8', 'ignore'))
else:
log.debug(u'no output')
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
util.remove(m3u.name)
class PlayPlugin(BeetsPlugin):
def __init__(self):
@ -131,10 +42,74 @@ class PlayPlugin(BeetsPlugin):
'play',
help='send music to a player as a playlist'
)
play_command.parser.add_option(
'-a', '--album',
action='store_true', default=False,
help='query and load albums rather than tracks'
)
play_command.func = partial(play_music, log=self._log)
play_command.parser.add_album_option()
play_command.func = self.play_music
return [play_command]
def play_music(self, lib, opts, args):
"""Execute query, create temporary playlist and execute player
command passing that playlist.
"""
command_str = config['play']['command'].get()
use_folders = config['play']['use_folders'].get(bool)
relative_to = config['play']['relative_to'].get()
if relative_to:
relative_to = util.normpath(relative_to)
# Perform search by album and add folders rather than tracks to
# playlist.
if opts.album:
selection = lib.albums(ui.decargs(args))
paths = []
sort = lib.get_default_album_sort()
for album in selection:
if use_folders:
paths.append(album.item_dir())
else:
paths.extend(item.path
for item in sort.sort(album.items()))
item_type = 'album'
# Perform item query and add tracks to playlist.
else:
selection = lib.items(ui.decargs(args))
paths = [item.path for item in selection]
item_type = 'track'
item_type += 's' if len(selection) > 1 else ''
if not selection:
ui.print_(ui.colorize('text_warning',
'No {0} to play.'.format(item_type)))
return
# Warn user before playing any huge playlists.
if len(selection) > 100:
ui.print_(ui.colorize(
'text_warning',
'You are about to queue {0} {1}.'.format(len(selection),
item_type)
))
if ui.input_options(('Continue', 'Abort')) == 'a':
return
# Create temporary m3u file to hold our playlist.
m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False)
for item in paths:
if relative_to:
m3u.write(relpath(item, relative_to) + b'\n')
else:
m3u.write(item + b'\n')
m3u.close()
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
try:
util.interactive_open(m3u.name, command_str)
except OSError as exc:
raise ui.UserError("Could not play the music playlist: "
"{0}".format(exc))
finally:
util.remove(m3u.name)

View file

@ -55,7 +55,7 @@ class PlexUpdate(BeetsPlugin):
self.register_listener('database_change', self.listen_for_db_change)
def listen_for_db_change(self, lib):
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update for the end"""
self.register_listener('cli_exit', self.update)

View file

@ -26,7 +26,6 @@ from itertools import groupby
def random_item(lib, opts, args):
query = decargs(args)
fmt = '$path' if opts.path else opts.format
if opts.album:
objs = list(lib.albums(query))
@ -63,20 +62,15 @@ def random_item(lib, opts, args):
objs = random.sample(objs, number)
for item in objs:
print_(format(item, fmt))
print_(format(item))
random_cmd = Subcommand('random',
help='chose a random track or album')
random_cmd.parser.add_option('-a', '--album', action='store_true',
help='choose an album instead of track')
random_cmd.parser.add_option('-p', '--path', action='store_true',
help='print the path of the matched item')
random_cmd.parser.add_option('-f', '--format', action='store',
help='print with custom format', default='')
random_cmd.parser.add_option('-n', '--number', action='store', type="int",
help='number of objects to choose', default=1)
random_cmd.parser.add_option('-e', '--equal-chance', action='store_true',
help='each artist has the same chance')
random_cmd.parser.add_all_common_options()
random_cmd.func = random_item

View file

@ -21,6 +21,7 @@ import collections
import itertools
import sys
import warnings
import re
from beets import logging
from beets import ui
@ -68,6 +69,7 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
class Backend(object):
"""An abstract class representing engine for calculating RG values.
"""
def __init__(self, config, log):
"""Initialize the backend with the configuration view for the
plugin.
@ -83,10 +85,112 @@ class Backend(object):
raise NotImplementedError()
# bsg1770gain backend
class Bs1770gainBackend(Backend):
"""bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and
its flavors EBU R128, ATSC A/85 and Replaygain 2.0.
"""
def __init__(self, config, log):
super(Bs1770gainBackend, self).__init__(config, log)
cmd = b'bs1770gain'
try:
self.method = b'--' + config['method'].get(str)
except:
self.method = b'--replaygain'
try:
call([cmd, self.method])
self.command = cmd
except OSError:
raise FatalReplayGainError(
'Is bs1770gain installed? Is your method in config correct?'
)
if not self.command:
raise FatalReplayGainError(
'no replaygain command found: install bs1770gain'
)
def compute_track_gain(self, items):
"""Computes the track gain of the given tracks, returns a list
of TrackGain objects.
"""
output = self.compute_gain(items, False)
return output
def compute_album_gain(self, album):
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
# TODO: What should be done when not all tracks in the album are
# supported?
supported_items = album.items()
output = self.compute_gain(supported_items, True)
return AlbumGain(output[-1], output[:-1])
def compute_gain(self, items, is_album):
"""Computes the track or album gain of a list of items, returns
a list of TrackGain objects.
When computing album gain, the last TrackGain object returned is
the album gain
"""
if len(items) == 0:
return []
"""Compute ReplayGain values and return a list of results
dictionaries as given by `parse_tool_output`.
"""
# Construct shell command.
cmd = [self.command]
cmd = cmd + [self.method]
cmd = cmd + [b'-it']
cmd = cmd + [syspath(i.path) for i in items]
self._log.debug(u'analyzing {0} files', len(items))
self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd)))
output = call(cmd)
self._log.debug(u'analysis finished')
results = self.parse_tool_output(output,
len(items) + is_album)
return results
def parse_tool_output(self, text, num_lines):
"""Given the output from bs1770gain, parse the text and
return a list of dictionaries
containing information about each analyzed file.
"""
out = []
data = text.decode('utf8', errors='ignore')
regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]"
"|\s{2,2}\[ALBUM\]:|done\.$)")
results = re.findall(regex, data, re.S | re.M)
for ll in results[0:num_lines]:
parts = ll.split(b'\n')
if len(parts) == 0:
self._log.debug(u'bad tool output: {0!r}', text)
raise ReplayGainError('bs1770gain failed')
d = {
'file': parts[0],
'gain': float((parts[1].split('/'))[1].split('LU')[0]),
'peak': float(parts[2].split('/')[1]),
}
self._log.info('analysed {}gain={};peak={}',
d['file'].rstrip(), d['gain'], d['peak'])
out.append(Gain(d['gain'], d['peak']))
return out
# mpgain/aacgain CLI tool backend.
class CommandBackend(Backend):
def __init__(self, config, log):
super(CommandBackend, self).__init__(config, log)
config.add({
@ -106,9 +210,9 @@ class CommandBackend(Backend):
)
else:
# Check whether the program is in $PATH.
for cmd in ('mp3gain', 'aacgain'):
for cmd in (b'mp3gain', b'aacgain'):
try:
call([cmd, '-v'])
call([cmd, b'-v'])
self.command = cmd
except OSError:
pass
@ -172,15 +276,14 @@ class CommandBackend(Backend):
# 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']
cmd = [self.command, b'-o', b'-s', b's']
if self.noclip:
# Adjust to avoid clipping.
cmd = cmd + ['-k']
cmd = cmd + [b'-k']
else:
# Disable clipping warning.
cmd = cmd + ['-c']
cmd = cmd + ['-a' if is_album else '-r']
cmd = cmd + ['-d', bytes(self.gain_offset)]
cmd = cmd + [b'-c']
cmd = cmd + [b'-d', bytes(self.gain_offset)]
cmd = cmd + [syspath(i.path) for i in items]
self._log.debug(u'analyzing {0} files', len(items))
@ -219,6 +322,7 @@ class CommandBackend(Backend):
# GStreamer-based backend.
class GStreamerBackend(Backend):
def __init__(self, config, log):
super(GStreamerBackend, self).__init__(config, log)
self._import_gst()
@ -471,8 +575,9 @@ class AudioToolsBackend(Backend):
<http://audiotools.sourceforge.net/>`_ and its capabilities to read more
file formats and compute ReplayGain values using it replaygain module.
"""
def __init__(self, config, log):
super(CommandBackend, self).__init__(config, log)
super(AudioToolsBackend, self).__init__(config, log)
self._import_audiotools()
def _import_audiotools(self):
@ -599,9 +704,10 @@ class ReplayGainPlugin(BeetsPlugin):
"""
backends = {
"command": CommandBackend,
"command": CommandBackend,
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend
"audiotools": AudioToolsBackend,
"bs1770gain": Bs1770gainBackend
}
def __init__(self):
@ -761,7 +867,6 @@ class ReplayGainPlugin(BeetsPlugin):
self.handle_track(item, write)
cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain')
cmd.parser.add_option('-a', '--album', action='store_true',
help='analyze albums instead of tracks')
cmd.parser.add_album_option()
cmd.func = func
return [cmd]

View file

@ -139,7 +139,7 @@ class ScrubPlugin(BeetsPlugin):
self._log.error(u'could not scrub {0}: {1}',
util.displayable_path(path), exc)
def write_item(self, path):
def write_item(self, item, path, tags):
"""Automatically embed art into imported albums."""
if not scrubbing and self.config['auto']:
self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path))

View file

@ -21,30 +21,14 @@ from __future__ import (division, absolute_import, print_function,
from beets.plugins import BeetsPlugin
from beets import ui
from beets.util import mkdirall, normpath, syspath
from beets.library import Item, Album, parse_query_string
from beets.dbcore import OrQuery
from beets.dbcore.query import MultipleSort
import os
def _items_for_query(lib, queries, album):
"""Get the matching items for a list of queries.
`queries` can either be a single string or a list of strings. In the
latter case, the results from each query are concatenated. `album`
indicates whether the queries are item-level or album-level.
"""
if isinstance(queries, basestring):
queries = [queries]
if album:
for query in queries:
for album in lib.albums(query):
for item in album.items():
yield item
else:
for query in queries:
for item in lib.items(query):
yield item
class SmartPlaylistPlugin(BeetsPlugin):
def __init__(self):
super(SmartPlaylistPlugin, self).__init__()
self.config.add({
@ -54,42 +38,139 @@ class SmartPlaylistPlugin(BeetsPlugin):
'playlists': []
})
self._matched_playlists = None
self._unmatched_playlists = None
if self.config['auto']:
self.register_listener('database_change', self.db_change)
def commands(self):
def update(lib, opts, args):
self.update_playlists(lib)
spl_update = ui.Subcommand('splupdate',
help='update the smart playlists')
spl_update.func = update
help='update the smart playlists. Playlist '
'names may be passed as arguments.')
spl_update.func = self.update_cmd
return [spl_update]
def db_change(self, lib):
self.register_listener('cli_exit', self.update_playlists)
def update_cmd(self, lib, opts, args):
self.build_queries()
if args:
args = set(ui.decargs(args))
for a in list(args):
if not a.endswith(".m3u"):
args.add("{0}.m3u".format(a))
playlists = set((name, q, a_q)
for name, q, a_q in self._unmatched_playlists
if name in args)
if not playlists:
raise ui.UserError('No playlist matching any of {0} '
'found'.format([name for name, _, _ in
self._unmatched_playlists]))
self._matched_playlists = playlists
self._unmatched_playlists -= playlists
else:
self._matched_playlists = self._unmatched_playlists
self.update_playlists(lib)
def build_queries(self):
"""
Instanciate queries for the playlists.
Each playlist has 2 queries: one or items one for albums, each with a
sort. We must also remember its name. _unmatched_playlists is a set of
tuples (name, (q, q_sort), (album_q, album_q_sort)).
sort may be any sort, or NullSort, or None. None and NullSort are
equivalent and both eval to False.
More precisely
- it will be NullSort when a playlist query ('query' or 'album_query')
is a single item or a list with 1 element
- it will be None when there are multiple items i a query
"""
self._unmatched_playlists = set()
self._matched_playlists = set()
for playlist in self.config['playlists'].get(list):
playlist_data = (playlist['name'],)
for key, Model in (('query', Item), ('album_query', Album)):
qs = playlist.get(key)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, basestring):
query_and_sort = parse_query_string(qs, Model)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], Model)
else:
# multiple queries and sorts
queries, sorts = zip(*(parse_query_string(q, Model)
for q in qs))
query = OrQuery(queries)
final_sorts = []
for s in sorts:
if s:
if isinstance(s, MultipleSort):
final_sorts += s.sorts
else:
final_sorts.append(s)
if not final_sorts:
sort = None
elif len(final_sorts) == 1:
sort, = final_sorts
else:
sort = MultipleSort(final_sorts)
query_and_sort = query, sort
playlist_data += (query_and_sort,)
self._unmatched_playlists.add(playlist_data)
def matches(self, model, query, album_query):
if album_query and isinstance(model, Album):
return album_query.match(model)
if query and isinstance(model, Item):
return query.match(model)
return False
def db_change(self, lib, model):
if self._unmatched_playlists is None:
self.build_queries()
for playlist in self._unmatched_playlists:
n, (q, _), (a_q, _) = playlist
if self.matches(model, q, a_q):
self._log.debug("{0} will be updated because of {1}", n, model)
self._matched_playlists.add(playlist)
self.register_listener('cli_exit', self.update_playlists)
self._unmatched_playlists -= self._matched_playlists
def update_playlists(self, lib):
self._log.info("Updating smart playlists...")
playlists = self.config['playlists'].get(list)
self._log.info("Updating {0} smart playlists...",
len(self._matched_playlists))
playlist_dir = self.config['playlist_dir'].as_filename()
relative_to = self.config['relative_to'].get()
if relative_to:
relative_to = normpath(relative_to)
for playlist in playlists:
self._log.debug(u"Creating playlist {0[name]}", playlist)
for playlist in self._matched_playlists:
name, (query, q_sort), (album_query, a_q_sort) = playlist
self._log.debug(u"Creating playlist {0}", name)
items = []
if 'album_query' in playlist:
items.extend(_items_for_query(lib, playlist['album_query'],
True))
if 'query' in playlist:
items.extend(_items_for_query(lib, playlist['query'], False))
if query:
items.extend(lib.items(query, q_sort))
if album_query:
for album in lib.albums(album_query, a_q_sort):
items.extend(album.items())
m3us = {}
# As we allow tags in the m3u names, we'll need to iterate through
# the items and generate the correct m3u file names.
for item in items:
m3u_name = item.evaluate_template(playlist['name'], True)
m3u_name = item.evaluate_template(name, True)
if m3u_name not in m3us:
m3us[m3u_name] = []
item_path = item.path
@ -104,4 +185,4 @@ class SmartPlaylistPlugin(BeetsPlugin):
with open(syspath(m3u_path), 'w') as f:
for path in m3us[m3u]:
f.write(path + b'\n')
self._log.info("{0} playlists updated", len(playlists))
self._log.info("{0} playlists updated", len(self._matched_playlists))

View file

@ -30,12 +30,6 @@ FORMAT = u'{0}, {1}'
class ThePlugin(BeetsPlugin):
_instance = None
the = True
a = True
format = u''
strip = False
patterns = []
def __init__(self):

View file

@ -6,6 +6,23 @@ Changelog
Features:
* :doc:`/plugins/smartplaylist`: detect for each playlist if it needs to be
regenated, instead of systematically regenerating all of them after a
database modification.
* :doc:`/plugins/smartplaylist`: the ``splupdate`` command can now take
additinal parameters: names of the playlists to regenerate.
* Beets now accept top-level options ``--format-item`` and ``--format-album``
before any subcommand to control how items and albums are displayed.
:bug:`1271`
* :doc:`/plugins/replaygain`: There is a new backend for the `bs1770gain`_
tool. Thanks to :user:`jmwatte`. :bug:`1343`
* There are now multiple levels of verbosity. On the command line, you can
make beets somewhat verbose with ``-v`` or very verbose with ``-vv``.
:bug:`1244`
* :doc:`/plugins/play` will sort items according to the configured option when
used in album mode.
* :doc:`/plugins/play` gives full interaction with the command invoked.
:bug:`1321`
* The summary shown to compare duplicate albums during import now displays
the old and new filesizes. :bug:`1291`
* The colors used are now configurable via the new config option ``colors``,
@ -46,6 +63,10 @@ Features:
* A new ``filesize`` field on items indicates the number of bytes in the file.
:bug:`1291`
* The number of missing/unmatched tracks is shown during import. :bug:`1088`
* The data source used during import (e.g., MusicBrainz) is now saved as a
flexible attribute `data_source` of an Item/Album. :bug:`1311`
* :doc:`/plugins/permissions`: Now handles also the permissions of the
directories. :bug:`1308` :bug:`1324`
Core changes:
@ -60,13 +81,19 @@ Core changes:
``albumtotal`` computed attribute that provides the total number of tracks
on the album. (The :ref:`per_disc_numbering` option has no influence on this
field.)
* The :ref:`list_format_album` and :ref:`list_format_item` configuration keys
* The `list_format_album` and `list_format_item` configuration keys
now affect (almost) every place where objects are printed and logged.
(Previously, they only controlled the :ref:`list-cmd` command and a few
other scattered pieces.) :bug:`1269`
* `list_format_album` and `list_format_album` have respectively been
renamed :ref:`format_album` and :ref:`format_item`. The old names still work
but each triggers a warning message. :bug:`1271`
Fixes:
* :doc:`/plugins/replaygain`: Stop applying replaygain directly to source files
when using the mp3gain backend. :bug:`1316`
* Path queries are case-sensitive on non-Windows OSes. :bug:`1165`
* :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new
MusixMatch backend. :bug:`1204`
* Fix a crash when ``beet`` is invoked without arguments. :bug:`1205`
@ -105,18 +132,29 @@ Fixes:
Unicode filenames. :bug:`1297`
* :doc:`/plugins/discogs`: Handle and log more kinds of communication
errors. :bug:`1299` :bug:`1305`
* :doc:`/plugins/lastgenre`: Bugs in the `pylast` library can no longer crash
beets.
For developers:
* The ``database_change`` event now sends the item or album that is subject to
a change in the db.
* the ``OptionParser`` is now a ``CommonOptionsParser`` that offers facilities
for adding usual options (``--album``, ``--path`` and ``--format``). See
:ref:`add_subcommands`. :bug:`1271`
* The logging system in beets has been overhauled. Plugins now each have their
own logger, which helps by automatically adjusting the verbosity level in
import mode and by prefixing the plugin's name. Also, logging calls can (and
import mode and by prefixing the plugin's name. Logging levels are
dynamically set when a plugin is called, depending on how it is called
(import stage, event or direct command). Finally, logging calls can (and
should!) use modern ``{}``-style string formatting lazily. See
:ref:`plugin-logging` in the plugin API docs.
* A new ``import_task_created`` event lets you manipulate import tasks
immediately after they are initialized. It's also possible to replace the
originally created tasks by returning new ones using this event.
.. _bs1770gain: http://bs1770gain.sourceforge.net
1.3.10 (January 5, 2015)
------------------------

View file

@ -23,7 +23,7 @@ extlinks = {
}
# Options for HTML output
html_theme = 'default'
html_theme = 'classic'
htmlhelp_basename = 'beetsdoc'
# Options for LaTeX output

View file

@ -87,8 +87,11 @@ The function should use any of the utility functions defined in ``beets.ui``.
Try running ``pydoc beets.ui`` to see what's available.
You can add command-line options to your new command using the ``parser`` member
of the ``Subcommand`` class, which is an ``OptionParser`` instance. Just use it
like you would a normal ``OptionParser`` in an independent script.
of the ``Subcommand`` class, which is a ``CommonOptionParser`` instance. Just
use it like you would a normal ``OptionParser`` in an independent script. Note
that it offers several methods to add common options: ``--album``, ``--path``
and ``--format``. This feature is versatile and extensively documented, try
``pydoc beets.ui.CommonOptionParser`` for more information.
.. _plugin_events:
@ -200,7 +203,7 @@ The events currently available are:
Library object. Parameter: ``lib``.
* *database_change*: a modification has been made to the library database. The
change might not be committed yet. Parameter: ``lib``.
change might not be committed yet. Parameters: ``lib`` and ``model``.
* *cli_exit*: called just before the ``beet`` command-line program exits.
Parameter: ``lib``.
@ -382,7 +385,7 @@ Multiple stages run in parallel but each stage processes only one task at a time
and each task is processed by only one stage at a time.
Plugins provide stages as functions that take two arguments: ``config`` and
``task``, which are ``ImportConfig`` and ``ImportTask`` objects (both defined in
``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined in
``beets.importer``). Add such a function to the plugin's ``import_stages`` field
to register it::
@ -391,7 +394,7 @@ to register it::
def __init__(self):
super(ExamplePlugin, self).__init__()
self.import_stages = [self.stage]
def stage(self, config, task):
def stage(self, session, task):
print('Importing something!')
.. _extend-query:
@ -480,14 +483,21 @@ str.format-style string formatting. So you can write logging calls like this::
.. _PEP 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard Python logging module: https://docs.python.org/2/library/logging.html
The per-plugin loggers have two convenient features:
When beets is in verbose mode, plugin messages are prefixed with the plugin
name to make them easier to see.
What messages will be logged depends on the logging level and the action
performed:
* On import stages and event, the default is ``WARNING`` messages.
* On direct actions, the default is ``INFO`` and ``WARNING`` message.
The verbosity can be increased with ``--verbose`` flags: each flags lowers the
level by a notch.
This addresses a common pattern where plugins need to use the same code for a
command and an import stage, but the command needs to print more messages than
the import stage. (For example, you'll want to log "found lyrics for this song"
when you're run explicitly as a command, but you don't want to noisily
interrupt the importer interface when running automatically.)
* When beets is in verbose mode, messages are prefixed with the plugin name to
make them easier to see.
* Messages at the ``INFO`` logging level are hidden when the plugin is running
in an importer stage (see above). This addresses a common pattern where
plugins need to use the same code for a command and an import stage, but the
command needs to print more messages than the import stage. (For example,
you'll want to log "found lyrics for this song" when you're run explicitly
as a command, but you don't want to noisily interrupt the importer interface
when running automatically.)

View file

@ -61,7 +61,7 @@ file. The available options mirror the command-line options:
or album. This uses the same template syntax as beets'
:doc:`path formats</reference/pathformat>`. The usage is inspired by, and
therefore similar to, the :ref:`list <list-cmd>` command.
Default: :ref:`list_format_item`
Default: :ref:`format_item`
- **full**: List every track or album that has duplicates, not just the
duplicates themselves.
Default: ``no``.

View file

@ -203,6 +203,8 @@ Here are a few of the plugins written by the beets community:
* `beets-follow`_ lets you check for new albums from artists you like.
* `beets-setlister`_ generate playlists from the setlists of a given artist
.. _beets-check: https://github.com/geigerzaehler/beets-check
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
@ -216,3 +218,4 @@ Here are a few of the plugins written by the beets community:
.. _beet-amazon: https://github.com/jmwatte/beet-amazon
.. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives
.. _beets-follow: https://github.com/nolsto/beets-follow
.. _beets-setlister: https://github.com/tomjaspers/beets-setlister

View file

@ -34,5 +34,5 @@ The command has a few command-line options:
plugin will write new metadata to files' tags. To disable this, use the
``-W`` (``--nowrite``) option.
* To customize the output of unrecognized items, use the ``-f``
(``--format``) option. The default output is ``list_format_item`` or
``list_format_album`` for items and albums, respectively.
(``--format``) option. The default output is ``format_item`` or
``format_album`` for items and albums, respectively.

View file

@ -36,7 +36,7 @@ configuration file. The available options are:
track. This uses the same template syntax as beets'
:doc:`path formats </reference/pathformat>`. The usage is inspired by, and
therefore similar to, the :ref:`list <list-cmd>` command.
Default: :ref:`list_format_item`.
Default: :ref:`format_item`.
- **total**: Print a single count of missing tracks in all albums.
Default: ``no``.

View file

@ -2,7 +2,7 @@ Permissions Plugin
==================
The ``permissions`` plugin allows you to set file permissions for imported
music files.
music files and its directories.
To use the ``permissions`` plugin, enable it in your configuration (see
:ref:`using-plugins`). Permissions will be adjusted automatically on import.
@ -12,9 +12,10 @@ Configuration
To configure the plugin, make an ``permissions:`` section in your configuration
file. The ``file`` config value therein uses **octal modes** to specify the
desired permissions. The default flags are octal 644.
desired permissions. The default flags for files are octal 644 and 755 for directories.
Here's an example::
permissions:
file: 644
dir: 755

View file

@ -26,8 +26,8 @@ would on the command-line)::
play:
command: /usr/bin/command --option1 --option2 some_other_option
Enable beets' verbose logging to see the command's output if you need to
debug.
While playing you'll be able to interact with the player if it is a
command-line oriented, and you'll get its output in real time.
Configuration
-------------

View file

@ -10,9 +10,9 @@ playback levels.
Installation
------------
This plugin can use one of three backends to compute the ReplayGain values:
GStreamer, mp3gain (and its cousin, aacgain), and Python Audio Tools. mp3gain
can be easier to install but GStreamer and Audio Tools support more audio
This plugin can use one of four backends to compute the ReplayGain values:
GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools and bs1770gain. mp3gain
can be easier to install but GStreamer, Audio Tools and bs1770gain support more audio
formats.
Once installed, this plugin analyzes all files during the import process. This
@ -75,6 +75,23 @@ On OS X, most of the dependencies can be installed with `Homebrew`_::
.. _Python Audio Tools: http://audiotools.sourceforge.net
bs1770gain
``````````
In order to use this backend, you will need to install the bs1770gain command-line tool. Here are some hints:
* goto `bs1770gain`_ and follow the download instructions
* make sure it is in your $PATH
.. _bs1770gain: http://bs1770gain.sourceforge.net/
Then, enable the plugin (see :ref:`using-plugins`) and specify the
backend in your configuration file::
replaygain:
backend: bs1770gain
Configuration
-------------
@ -92,11 +109,6 @@ configuration file. The available options are:
These options only work with the "command" backend:
- **apply**: 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.
Default: ``no``.
- **command**: The path to the ``mp3gain`` or ``aacgain`` executable (if beets
cannot find it by itself).
For example: ``/Applications/MacMP3Gain.app/Contents/Resources/aacgain``.
@ -105,6 +117,14 @@ These options only work with the "command" backend:
would keep clipping from occurring.
Default: ``yes``.
This option only works with the "bs1770gain" backend:
- **method**: The loudness scanning standard: either `replaygain` for
ReplayGain 2.0, `ebu` for EBU R128, or `atsc` for ATSC A/85. This dictates
the reference level: -18, -23, or -24 LUFS respectively. Default:
`replaygain`
Manual Analysis
---------------

View file

@ -44,6 +44,18 @@ You can also gather the results of several queries by putting them in a list.
- name: 'BeatlesUniverse.m3u'
query: ['artist:beatles', 'genre:"beatles cover"']
Note that since beets query syntax is in effect, you can also use sorting
directives::
- name: 'Chronological Beatles'
query: 'artist:Beatles year+'
- name: 'Mixed Rock'
query: ['artist:Beatles year+', 'artist:"Led Zeppelin" bitrate+']
The former case behaves as expected, however please note that in the latter the
sorts will be merged: ``year+ bitrate+`` will apply to both the Beatles and Led
Zeppelin. If that bothers you, please get in touch.
For querying albums instead of items (mainly useful with extensible fields),
use the ``album_query`` field. ``query`` and ``album_query`` can be used at the
same time. The following example gathers single items but also items belonging
@ -53,13 +65,16 @@ to albums that have a ``for_travel`` extensible field set to 1::
album_query: 'for_travel:1'
query: 'for_travel:1'
By default, all playlists are automatically regenerated at the end of the
session if the library database was changed. To force regeneration, you can
invoke it manually from the command line::
By default, each playlist is automatically regenerated at the end of the
session if an item or album it matches changed in the library database. To
force regeneration, you can invoke it manually from the command line::
$ beet splupdate
which will generate your new smart playlists.
This will regenerate all smart playlists. You can also specify which ones you
want to regenerate::
$ beet splupdate BeatlesUniverse.m3u MyTravelPlaylist
You can also use this plugin together with the :doc:`mpdupdate`, in order to
automatically notify MPD of the playlist change, by adding ``mpdupdate`` to

View file

@ -369,7 +369,8 @@ import ...``.
* ``-l LIBPATH``: specify the library database file to use.
* ``-d DIRECTORY``: specify the library root directory.
* ``-v``: verbose mode; prints out a deluge of debugging information. Please use
this flag when reporting bugs.
this flag when reporting bugs. You can use it twice, as in ``-vv``, to make
beets even more verbose.
* ``-c FILE``: read a specified YAML :doc:`configuration file <config>`.
Beets also uses the ``BEETSDIR`` environment variable to look for

View file

@ -162,25 +162,32 @@ Either ``yes`` or ``no``, indicating whether the autotagger should use
multiple threads. This makes things faster but may behave strangely.
Defaults to ``yes``.
.. _list_format_item:
list_format_item
~~~~~~~~~~~~~~~~
.. _list_format_item:
.. _format_item:
format_item
~~~~~~~~~~~
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:
It used to be named `list_format_item`.
list_format_album
~~~~~~~~~~~~~~~~~
.. _list_format_album:
.. _format_album:
format_album
~~~~~~~~~~~~
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.
It used to be named `list_format_album`.
.. _sort_item:
sort_item

View file

@ -184,6 +184,8 @@ Note that this only matches items that are *already in your library*, so a path
query won't necessarily find *all* the audio files in a directory---just the
ones you've already added to your beets library.
Path queries are case-sensitive on most platforms but case-insensitive on
Windows.
.. _query-sort:

View file

@ -82,9 +82,8 @@ setup(
'unidecode',
'musicbrainzngs>=0.4',
'pyyaml',
]
+ (['colorama'] if (sys.platform == 'win32') else [])
+ (['ordereddict'] if sys.version_info < (2, 7, 0) else []),
] + (['colorama'] if (sys.platform == 'win32') else []) +
(['ordereddict'] if sys.version_info < (2, 7, 0) else []),
tests_require=[
'beautifulsoup4',

View file

@ -15,7 +15,7 @@
"""This module includes various helpers that provide fixtures, capture
information or mock the environment.
- The `control_stdin` and `capture_output` context managers allow one to
- The `control_stdin` and `capture_stdout` context managers allow one to
interact with the user interface.
- `has_program` checks the presence of a command on the system.
@ -51,6 +51,7 @@ from beets.library import Library, Item, Album
from beets import importer
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.mediafile import MediaFile, Image
from beets.ui import _encoding
# TODO Move AutotagMock here
from test import _common
@ -117,9 +118,13 @@ def capture_stdout():
def has_program(cmd, args=['--version']):
"""Returns `True` if `cmd` can be executed.
"""
full_cmd = [cmd] + args
for i, elem in enumerate(full_cmd):
if isinstance(elem, unicode):
full_cmd[i] = elem.encode(_encoding())
try:
with open(os.devnull, 'wb') as devnull:
subprocess.check_call([cmd] + args, stderr=devnull,
subprocess.check_call(full_cmd, stderr=devnull,
stdout=devnull, stdin=devnull)
except OSError:
return False
@ -167,7 +172,7 @@ class TestHelper(object):
self.config.read()
self.config['plugins'] = []
self.config['verbose'] = True
self.config['verbose'] = 1
self.config['ui']['color'] = False
self.config['threaded'] = False

View file

@ -789,6 +789,13 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.assertEqual(self.items[0].month, 2)
self.assertEqual(self.items[0].day, 3)
def test_data_source_applied(self):
my_info = copy.deepcopy(self.info)
my_info.data_source = 'MusicBrainz'
self._apply(info=my_info)
self.assertEqual(self.items[0].data_source, 'MusicBrainz')
class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
def setUp(self):

View file

@ -10,7 +10,6 @@ from shutil import rmtree
from beets import ui
from beets import config
from test import _common
from test._common import unittest
from test.helper import TestHelper, capture_stdout
from beets.library import Library
@ -81,33 +80,22 @@ class ConfigCommandTest(unittest.TestCase, TestHelper):
execlp.assert_called_once_with(
'myeditor', 'myeditor', self.config_path)
def test_edit_config_with_open(self):
with _common.system_mock('Darwin'):
def test_edit_config_with_automatic_open(self):
with patch('beets.util.open_anything') as open:
open.return_value = 'please_open'
with patch('os.execlp') as execlp:
self.run_command('config', '-e')
execlp.assert_called_once_with(
'open', 'open', '-n', self.config_path)
def test_edit_config_with_xdg_open(self):
with _common.system_mock('Linux'):
with patch('os.execlp') as execlp:
self.run_command('config', '-e')
execlp.assert_called_once_with(
'xdg-open', 'xdg-open', self.config_path)
def test_edit_config_with_windows_exec(self):
with _common.system_mock('Windows'):
with patch('os.execlp') as execlp:
self.run_command('config', '-e')
execlp.assert_called_once_with(self.config_path, self.config_path)
'please_open', 'please_open', self.config_path)
def test_config_editor_not_found(self):
with self.assertRaises(ui.UserError) as user_error:
with patch('os.execlp') as execlp:
execlp.side_effect = OSError()
execlp.side_effect = OSError('here is problem')
self.run_command('config', '-e')
self.assertIn('Could not edit configuration',
unicode(user_error.exception.args[0]))
unicode(user_error.exception))
self.assertIn('here is problem', unicode(user_error.exception))
def test_edit_invalid_config_file(self):
self.lib = Library(':memory:')

View file

@ -449,6 +449,7 @@ class SortFromStringsTest(unittest.TestCase):
def test_zero_parts(self):
s = self.sfs([])
self.assertIsInstance(s, dbcore.query.NullSort)
self.assertEqual(s, dbcore.query.NullSort())
def test_one_parts(self):
s = self.sfs(['field+'])
@ -461,17 +462,17 @@ class SortFromStringsTest(unittest.TestCase):
def test_fixed_field_sort(self):
s = self.sfs(['field_one+'])
self.assertIsInstance(s, dbcore.query.MultipleSort)
self.assertIsInstance(s.sorts[0], dbcore.query.FixedFieldSort)
self.assertIsInstance(s, dbcore.query.FixedFieldSort)
self.assertEqual(s, dbcore.query.FixedFieldSort('field_one'))
def test_flex_field_sort(self):
s = self.sfs(['flex_field+'])
self.assertIsInstance(s, dbcore.query.MultipleSort)
self.assertIsInstance(s.sorts[0], dbcore.query.SlowFieldSort)
self.assertIsInstance(s, dbcore.query.SlowFieldSort)
self.assertEqual(s, dbcore.query.SlowFieldSort('flex_field'))
def test_special_sort(self):
s = self.sfs(['some_sort+'])
self.assertIsInstance(s.sorts[0], TestSort)
self.assertIsInstance(s, TestSort)
class ResultsIteratorTest(unittest.TestCase):

View file

@ -16,6 +16,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os.path
import shutil
from mock import Mock, patch
from test import _common
@ -41,7 +42,7 @@ def require_artresizer_compare(test):
return wrapper
class EmbedartCliTest(unittest.TestCase, TestHelper):
class EmbedartCliTest(_common.TestCase, TestHelper):
small_artpath = os.path.join(_common.RSRC, 'image-2x3.jpg')
abbey_artpath = os.path.join(_common.RSRC, 'abbey.jpg')
@ -114,6 +115,18 @@ class EmbedartCliTest(unittest.TestCase, TestHelper):
'Image written is not {0}'.format(
self.abbey_similarpath))
def test_non_ascii_album_path(self):
resource_path = os.path.join(_common.RSRC, 'image.mp3')
album = self.add_album_fixture()
trackpath = album.items()[0].path
albumpath = album.path
shutil.copy(resource_path, trackpath.decode('utf-8'))
self.run_command('extractart', '-n', 'extracted')
self.assertExists(os.path.join(albumpath.decode('utf-8'),
'extracted.png'))
class EmbedartTest(unittest.TestCase):
@patch('beetsplug.embedart.subprocess')

View file

@ -625,6 +625,15 @@ class ImportTest(_common.TestCase, ImportHelper):
self.assertIn('No files imported from {0}'.format(import_dir), logs)
def test_asis_no_data_source(self):
self.assertEqual(self.lib.items().get(), None)
self.importer.add_choice(importer.action.ASIS)
self.importer.run()
with self.assertRaises(AttributeError):
self.lib.items().get().data_source
class ImportTracksTest(_common.TestCase, ImportHelper):
"""Test TRACKS and APPLY choice.

View file

@ -31,7 +31,7 @@ from test._common import unittest
from test._common import item
import beets.library
import beets.mediafile
import beets.dbcore
import beets.dbcore.query
from beets import util
from beets import plugins
from beets import config
@ -240,27 +240,6 @@ class DestinationTest(_common.TestCase):
self.assertFalse('two \\ three' in p)
self.assertFalse('two / three' in p)
def test_sanitize_unix_replaces_leading_dot(self):
with _common.platform_posix():
p = util.sanitize_path(u'one/.two/three')
self.assertFalse('.' in p)
def test_sanitize_windows_replaces_trailing_dot(self):
with _common.platform_windows():
p = util.sanitize_path(u'one/two./three')
self.assertFalse('.' in p)
def test_sanitize_windows_replaces_illegal_chars(self):
with _common.platform_windows():
p = util.sanitize_path(u':*?"<>|')
self.assertFalse(':' in p)
self.assertFalse('*' in p)
self.assertFalse('?' in p)
self.assertFalse('"' in p)
self.assertFalse('<' in p)
self.assertFalse('>' in p)
self.assertFalse('|' in p)
def test_path_with_format(self):
self.lib.path_formats = [('default', '$artist/$album ($format)')]
p = self.i.destination()
@ -337,11 +316,6 @@ class DestinationTest(_common.TestCase):
]
self.assertEqual(self.i.destination(), np('one/three'))
def test_sanitize_windows_replaces_trailing_space(self):
with _common.platform_windows():
p = util.sanitize_path(u'one/two /three')
self.assertFalse(' ' in p)
def test_get_formatted_does_not_replace_separators(self):
with _common.platform_posix():
name = os.path.join('a', 'b')
@ -407,25 +381,6 @@ class DestinationTest(_common.TestCase):
p = self.i.destination()
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something')
def test_sanitize_path_works_on_empty_string(self):
with _common.platform_posix():
p = util.sanitize_path(u'')
self.assertEqual(p, u'')
def test_sanitize_with_custom_replace_overrides_built_in_sub(self):
with _common.platform_posix():
p = util.sanitize_path(u'a/.?/b', [
(re.compile(r'foo'), u'bar'),
])
self.assertEqual(p, u'a/.?/b')
def test_sanitize_with_custom_replace_adds_replacements(self):
with _common.platform_posix():
p = util.sanitize_path(u'foo/bar', [
(re.compile(r'foo'), u'bar'),
])
self.assertEqual(p, u'bar/bar')
def test_unicode_normalized_nfd_on_mac(self):
instr = unicodedata.normalize('NFC', u'caf\xe9')
self.lib.path_formats = [('default', instr)]
@ -474,14 +429,6 @@ class DestinationTest(_common.TestCase):
self.assertEqual(self.i.destination(),
np('base/ber/foo'))
@unittest.skip('unimplemented: #359')
def test_sanitize_empty_component(self):
with _common.platform_posix():
p = util.sanitize_path(u'foo//bar', [
(re.compile(r'^$'), u'_'),
])
self.assertEqual(p, u'foo/_/bar')
@unittest.skip('unimplemented: #359')
def test_destination_with_empty_component(self):
self.lib.directory = 'base'
@ -505,6 +452,22 @@ class DestinationTest(_common.TestCase):
self.assertEqual(self.i.destination(),
np('base/one/_.mp3'))
@unittest.skip('unimplemented: #496')
def test_truncation_does_not_conflict_with_replacement(self):
# Use a replacement that should always replace the last X in any
# path component with a Z.
self.lib.replacements = [
(re.compile(r'X$'), u'Z'),
]
# Construct an item whose untruncated path ends with a Y but whose
# truncated version ends with an X.
self.i.title = 'X' * 300 + 'Y'
# The final path should reflect the replacement.
dest = self.i.destination()
self.assertTrue('XZ' in dest)
class ItemFormattedMappingTest(_common.LibTestCase):
def test_formatted_item_value(self):
@ -700,49 +663,6 @@ class DisambiguationTest(_common.TestCase, PathFormattingMixin):
self._assert_dest('/base/foo [foo_bar]/the title', self.i1)
class PathConversionTest(_common.TestCase):
def test_syspath_windows_format(self):
with _common.platform_windows():
path = os.path.join('a', 'b', 'c')
outpath = util.syspath(path)
self.assertTrue(isinstance(outpath, unicode))
self.assertTrue(outpath.startswith(u'\\\\?\\'))
def test_syspath_windows_format_unc_path(self):
# The \\?\ prefix on Windows behaves differently with UNC
# (network share) paths.
path = '\\\\server\\share\\file.mp3'
with _common.platform_windows():
outpath = util.syspath(path)
self.assertTrue(isinstance(outpath, unicode))
self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3')
def test_syspath_posix_unchanged(self):
with _common.platform_posix():
path = os.path.join('a', 'b', 'c')
outpath = util.syspath(path)
self.assertEqual(path, outpath)
def _windows_bytestring_path(self, path):
old_gfse = sys.getfilesystemencoding
sys.getfilesystemencoding = lambda: 'mbcs'
try:
with _common.platform_windows():
return util.bytestring_path(path)
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(_common.TestCase):
def setUp(self):
super(PluginDestinationTest, self).setUp()
@ -1004,23 +924,6 @@ class PathStringTest(_common.TestCase):
self.assert_(isinstance(alb.artpath, bytes))
class PathTruncationTest(_common.TestCase):
def test_truncate_bytestring(self):
with _common.platform_posix():
p = util.truncate_path('abcde/fgh', 4)
self.assertEqual(p, 'abcd/fgh')
def test_truncate_unicode(self):
with _common.platform_posix():
p = util.truncate_path(u'abcde/fgh', 4)
self.assertEqual(p, u'abcd/fgh')
def test_truncate_preserves_extension(self):
with _common.platform_posix():
p = util.truncate_path(u'abcde/fgh.ext', 5)
self.assertEqual(p, u'abcde/f.ext')
class MtimeTest(_common.TestCase):
def setUp(self):
super(MtimeTest, self).setUp()
@ -1085,7 +988,7 @@ class TemplateTest(_common.LibTestCase):
self.assertEqual(self.i.evaluate_template('$foo'), 'baz')
def test_album_and_item_format(self):
config['list_format_album'] = u'foö $foo'
config['format_album'] = u'foö $foo'
album = beets.library.Album()
album.foo = 'bar'
album.tagada = 'togodo'
@ -1094,7 +997,7 @@ class TemplateTest(_common.LibTestCase):
self.assertEqual(unicode(album), u"foö bar")
self.assertEqual(str(album), b"fo\xc3\xb6 bar")
config['list_format_item'] = 'bar $foo'
config['format_item'] = 'bar $foo'
item = beets.library.Item()
item.foo = 'bar'
item.tagada = 'togodo'
@ -1172,10 +1075,32 @@ class ItemReadTest(unittest.TestCase):
item.read('/thisfiledoesnotexist')
class FilesizeTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
def tearDown(self):
self.teardown_beets()
def test_filesize(self):
item = self.add_item_fixture()
self.assertNotEquals(item.filesize, 0)
def test_nonexistent_file(self):
item = beets.library.Item()
self.assertEqual(item.filesize, 0)
class ParseQueryTest(unittest.TestCase):
def test_parse_invalid_query_string(self):
with self.assertRaises(beets.dbcore.InvalidQueryError):
with self.assertRaises(beets.dbcore.InvalidQueryError) as raised:
beets.library.parse_query_string('foo"', None)
self.assertIsInstance(raised.exception,
beets.dbcore.query.ParsingError)
def test_parse_bytes(self):
with self.assertRaises(AssertionError):
beets.library.parse_query_string(b"query", None)
def suite():

View file

@ -2,11 +2,15 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
import sys
import logging as log
from StringIO import StringIO
import beets.logging as blog
from beets import plugins, ui
import beetsplug
from test._common import unittest, TestCase
from test import helper
class LoggingTest(TestCase):
@ -37,6 +41,106 @@ class LoggingTest(TestCase):
self.assertTrue(stream.getvalue(), "foo oof baz")
class LoggingLevelTest(unittest.TestCase, helper.TestHelper):
class DummyModule(object):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
plugins.BeetsPlugin.__init__(self, 'dummy')
self.import_stages = [self.import_stage]
self.register_listener('dummy_event', self.listener)
def log_all(self, name):
self._log.debug('debug ' + name)
self._log.info('info ' + name)
self._log.warning('warning ' + name)
def commands(self):
cmd = ui.Subcommand('dummy')
cmd.func = lambda _, __, ___: self.log_all('cmd')
return (cmd,)
def import_stage(self, session, task):
self.log_all('import_stage')
def listener(self):
self.log_all('listener')
def setUp(self):
sys.modules['beetsplug.dummy'] = self.DummyModule
beetsplug.dummy = self.DummyModule
self.setup_beets()
self.load_plugins('dummy')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
del beetsplug.dummy
sys.modules.pop('beetsplug.dummy')
def test_command_logging(self):
self.config['verbose'] = 0
with helper.capture_log() as logs:
self.run_command('dummy')
self.assertIn('dummy: warning cmd', logs)
self.assertIn('dummy: info cmd', logs)
self.assertNotIn('dummy: debug cmd', logs)
for level in (1, 2):
self.config['verbose'] = level
with helper.capture_log() as logs:
self.run_command('dummy')
self.assertIn('dummy: warning cmd', logs)
self.assertIn('dummy: info cmd', logs)
self.assertIn('dummy: debug cmd', logs)
def test_listener_logging(self):
self.config['verbose'] = 0
with helper.capture_log() as logs:
plugins.send('dummy_event')
self.assertIn('dummy: warning listener', logs)
self.assertNotIn('dummy: info listener', logs)
self.assertNotIn('dummy: debug listener', logs)
self.config['verbose'] = 1
with helper.capture_log() as logs:
plugins.send('dummy_event')
self.assertIn('dummy: warning listener', logs)
self.assertIn('dummy: info listener', logs)
self.assertNotIn('dummy: debug listener', logs)
self.config['verbose'] = 2
with helper.capture_log() as logs:
plugins.send('dummy_event')
self.assertIn('dummy: warning listener', logs)
self.assertIn('dummy: info listener', logs)
self.assertIn('dummy: debug listener', logs)
def test_import_stage_logging(self):
self.config['verbose'] = 0
with helper.capture_log() as logs:
importer = self.create_importer()
importer.run()
self.assertIn('dummy: warning import_stage', logs)
self.assertNotIn('dummy: info import_stage', logs)
self.assertNotIn('dummy: debug import_stage', logs)
self.config['verbose'] = 1
with helper.capture_log() as logs:
importer = self.create_importer()
importer.run()
self.assertIn('dummy: warning import_stage', logs)
self.assertIn('dummy: info import_stage', logs)
self.assertNotIn('dummy: debug import_stage', logs)
self.config['verbose'] = 2
with helper.capture_log() as logs:
importer = self.create_importer()
importer.run()
self.assertIn('dummy: warning import_stage', logs)
self.assertIn('dummy: info import_stage', logs)
self.assertIn('dummy: debug import_stage', logs)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -316,6 +316,11 @@ class MBAlbumInfoTest(_common.TestCase):
self.assertEqual(track.artist_sort, 'TRACK ARTIST SORT NAME')
self.assertEqual(track.artist_credit, 'TRACK ARTIST CREDIT')
def test_data_source(self):
release = self._make_release()
d = mb.album_info(release)
self.assertEqual(d.data_source, 'MusicBrainz')
class ParseIDTest(_common.TestCase):
def test_parse_id_correct(self):

View file

@ -73,8 +73,8 @@ class MbsyncCliTest(unittest.TestCase, TestHelper):
self.assertEqual(album.album, 'album info')
def test_message_when_skipping(self):
config['list_format_item'] = '$artist - $album - $title'
config['list_format_album'] = '$albumartist - $album'
config['format_item'] = '$artist - $album - $title'
config['format_album'] = '$albumartist - $album'
# Test album with no mb_albumid.
# The default format for an album include $albumartist so
@ -99,6 +99,10 @@ class MbsyncCliTest(unittest.TestCase, TestHelper):
e = "mbsync: Skipping album with no mb_albumid: 'album info'"
self.assertEqual(e, logs[0])
# restore the config
config['format_item'] = '$artist - $album - $title'
config['format_album'] = '$albumartist - $album'
# Test singleton with no mb_trackid.
# The default singleton format includes $artist and $album
# so we need to stub them here

View file

@ -28,7 +28,7 @@ from test import _common
from test._common import unittest
from beets.mediafile import MediaFile, MediaField, Image, \
MP3DescStorageStyle, StorageStyle, MP4StorageStyle, \
ASFStorageStyle, ImageType
ASFStorageStyle, ImageType, CoverArtField
from beets.library import Item
from beets.plugins import BeetsPlugin
@ -161,6 +161,13 @@ class ImageStructureTestMixin(ArtTestMixin):
mediafile = MediaFile(mediafile.path)
self.assertEqual(len(mediafile.images), 0)
def test_guess_cover(self):
mediafile = self._mediafile_fixture('image')
self.assertEqual(len(mediafile.images), 2)
cover = CoverArtField.guess_cover_image(mediafile.images)
self.assertEqual(cover.desc, 'album cover')
self.assertEqual(mediafile.art, cover.data)
def assertExtendedImageAttributes(self, image, **kwargs):
"""Ignore extended image attributes in the base tests.
"""
@ -662,7 +669,7 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
errors.append('Tag %s does not exist' % key)
else:
if value2 != value:
errors.append('Tag %s: %s != %s' % (key, value2, value))
errors.append('Tag %s: %r != %r' % (key, value2, value))
if any(errors):
errors = ['Tags did not match'] + errors
self.fail('\n '.join(errors))
@ -758,6 +765,10 @@ class MP4Test(ReadWriteTestBase, PartialTestMixin,
with self.assertRaises(ValueError):
mediafile.images = [Image(data=self.tiff_data)]
def test_guess_cover(self):
# There is no metadata associated with images, we pick one at random
pass
class AlacTest(ReadWriteTestBase, unittest.TestCase):
extension = 'alac.m4a'

View file

@ -5,7 +5,9 @@ from __future__ import (division, absolute_import, print_function,
from test._common import unittest
from test.helper import TestHelper
from beetsplug.permissions import check_permissions, convert_perm
from beetsplug.permissions import (check_permissions,
convert_perm,
dirs_in_library)
class PermissionsPluginTest(unittest.TestCase, TestHelper):
@ -14,7 +16,8 @@ class PermissionsPluginTest(unittest.TestCase, TestHelper):
self.load_plugins('permissions')
self.config['permissions'] = {
'file': 777}
'file': 777,
'dir': 777}
def tearDown(self):
self.teardown_beets()
@ -24,23 +27,45 @@ class PermissionsPluginTest(unittest.TestCase, TestHelper):
self.importer = self.create_importer()
self.importer.run()
item = self.lib.items().get()
config_perm = self.config['permissions']['file'].get()
config_perm = convert_perm(config_perm)
self.assertTrue(check_permissions(item.path, config_perm))
file_perm = self.config['permissions']['file'].get()
file_perm = convert_perm(file_perm)
dir_perm = self.config['permissions']['dir'].get()
dir_perm = convert_perm(dir_perm)
music_dirs = dirs_in_library(self.config['directory'].get(),
item.path)
self.assertTrue(check_permissions(item.path, file_perm))
self.assertFalse(check_permissions(item.path, convert_perm(644)))
for path in music_dirs:
self.assertTrue(check_permissions(path, dir_perm))
self.assertFalse(check_permissions(path, convert_perm(644)))
def test_permissions_on_item_imported(self):
self.config['import']['singletons'] = True
self.importer = self.create_importer()
self.importer.run()
item = self.lib.items().get()
config_perm = self.config['permissions']['file'].get()
config_perm = convert_perm(config_perm)
self.assertTrue(check_permissions(item.path, config_perm))
file_perm = self.config['permissions']['file'].get()
file_perm = convert_perm(file_perm)
dir_perm = self.config['permissions']['dir'].get()
dir_perm = convert_perm(dir_perm)
music_dirs = dirs_in_library(self.config['directory'].get(),
item.path)
self.assertTrue(check_permissions(item.path, file_perm))
self.assertFalse(check_permissions(item.path, convert_perm(644)))
for path in music_dirs:
self.assertTrue(check_permissions(path, dir_perm))
self.assertFalse(check_permissions(path, convert_perm(644)))
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -16,8 +16,9 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
from mock import patch
from mock import patch, Mock
import shutil
import itertools
from beets.importer import SingletonImportTask, SentinelImportTask, \
ArchiveImportTask
@ -35,6 +36,7 @@ class TestHelper(helper.TestHelper):
def setup_plugin_loader(self):
# FIXME the mocking code is horrific, but this is the lowest and
# earliest level of the plugin mechanism we can hook into.
self.load_plugins()
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
self._plugin_classes = set()
load_plugins = self._plugin_loader_patch.start()
@ -56,7 +58,6 @@ class ItemTypesTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_plugin_loader()
self.setup_beets()
def tearDown(self):
self.teardown_plugin_loader()
@ -95,7 +96,7 @@ class ItemWriteTest(unittest.TestCase, TestHelper):
class EventListenerPlugin(plugins.BeetsPlugin):
pass
self.event_listener_plugin = EventListenerPlugin
self.event_listener_plugin = EventListenerPlugin()
self.register_plugin(EventListenerPlugin)
def tearDown(self):
@ -298,19 +299,105 @@ class ListenersTest(unittest.TestCase, TestHelper):
pass
d = DummyPlugin()
self.assertEqual(DummyPlugin.listeners['cli_exit'], [d.dummy])
self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy])
d2 = DummyPlugin()
DummyPlugin.register_listener('cli_exit', d.dummy)
self.assertEqual(DummyPlugin.listeners['cli_exit'],
self.assertEqual(DummyPlugin._raw_listeners['cli_exit'],
[d.dummy, d2.dummy])
@DummyPlugin.listen('cli_exit')
def dummy(lib):
pass
d.register_listener('cli_exit', d2.dummy)
self.assertEqual(DummyPlugin._raw_listeners['cli_exit'],
[d.dummy, d2.dummy])
self.assertEqual(DummyPlugin.listeners['cli_exit'],
[d.dummy, d2.dummy, dummy])
@patch('beets.plugins.find_plugins')
@patch('beets.plugins.inspect')
def test_events_called(self, mock_inspect, mock_find_plugins):
mock_inspect.getargspec.return_value = None
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.foo = Mock(__name__=b'foo')
self.register_listener('event_foo', self.foo)
self.bar = Mock(__name__=b'bar')
self.register_listener('event_bar', self.bar)
d = DummyPlugin()
mock_find_plugins.return_value = d,
plugins.send('event')
d.foo.assert_has_calls([])
d.bar.assert_has_calls([])
plugins.send('event_foo', var="tagada")
d.foo.assert_called_once_with(var="tagada")
d.bar.assert_has_calls([])
@patch('beets.plugins.find_plugins')
def test_listener_params(self, mock_find_plugins):
test = self
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
for i in itertools.count(1):
try:
meth = getattr(self, 'dummy{0}'.format(i))
except AttributeError:
break
self.register_listener('event{0}'.format(i), meth)
def dummy1(self, foo):
test.assertEqual(foo, 5)
def dummy2(self, foo=None):
test.assertEqual(foo, 5)
def dummy3(self):
# argument cut off
pass
def dummy4(self, bar=None):
# argument cut off
pass
def dummy5(self, bar):
test.assertFalse(True)
# more complex exmaples
def dummy6(self, foo, bar=None):
test.assertEqual(foo, 5)
test.assertEqual(bar, None)
def dummy7(self, foo, **kwargs):
test.assertEqual(foo, 5)
test.assertEqual(kwargs, {})
def dummy8(self, foo, bar, **kwargs):
test.assertFalse(True)
def dummy9(self, **kwargs):
test.assertEqual(kwargs, {"foo": 5})
d = DummyPlugin()
mock_find_plugins.return_value = d,
plugins.send('event1', foo=5)
plugins.send('event2', foo=5)
plugins.send('event3', foo=5)
plugins.send('event4', foo=5)
with self.assertRaises(TypeError):
plugins.send('event5', foo=5)
plugins.send('event6', foo=5)
plugins.send('event7', foo=5)
with self.assertRaises(TypeError):
plugins.send('event8', foo=5)
plugins.send('event9', foo=5)
def suite():

View file

@ -17,6 +17,8 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from functools import partial
from test import _common
from test._common import unittest
from test import helper
@ -24,7 +26,8 @@ from test import helper
import beets.library
from beets import dbcore
from beets.dbcore import types
from beets.dbcore.query import NoneQuery, InvalidQueryArgumentTypeError
from beets.dbcore.query import (NoneQuery, ParsingError,
InvalidQueryArgumentTypeError)
from beets.library import Library, Item
@ -57,6 +60,16 @@ class AnyFieldQueryTest(_common.LibTestCase):
dbcore.query.SubstringQuery)
self.assertEqual(self.lib.items(q).get(), None)
def test_eq(self):
q1 = dbcore.query.AnyFieldQuery('foo', ['bar'],
dbcore.query.SubstringQuery)
q2 = dbcore.query.AnyFieldQuery('foo', ['bar'],
dbcore.query.SubstringQuery)
self.assertEqual(q1, q2)
q2.query_class = None
self.assertNotEqual(q1, q2)
class AssertsMixin(object):
def assert_items_matched(self, results, titles):
@ -290,7 +303,7 @@ class GetTest(DummyDataTestCase):
dbcore.query.RegexpQuery('year', '199(')
self.assertIn('not a regular expression', unicode(raised.exception))
self.assertIn('unbalanced parenthesis', unicode(raised.exception))
self.assertIsInstance(raised.exception, TypeError)
self.assertIsInstance(raised.exception, ParsingError)
class MatchTest(_common.TestCase):
@ -341,6 +354,16 @@ class MatchTest(_common.TestCase):
def test_open_range(self):
dbcore.query.NumericQuery('bitrate', '100000..')
def test_eq(self):
q1 = dbcore.query.MatchQuery('foo', 'bar')
q2 = dbcore.query.MatchQuery('foo', 'bar')
q3 = dbcore.query.MatchQuery('foo', 'baz')
q4 = dbcore.query.StringFieldQuery('foo', 'bar')
self.assertEqual(q1, q2)
self.assertNotEqual(q1, q3)
self.assertNotEqual(q1, q4)
self.assertNotEqual(q3, q4)
class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin):
def setUp(self):
@ -460,6 +483,26 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin):
results = self.lib.albums(q)
self.assert_albums_matched(results, ['album with backslash'])
def test_case_sensitivity(self):
self.add_album(path='/A/B/C2.mp3', title='caps path')
makeq = partial(beets.library.PathQuery, 'path', '/A/B')
results = self.lib.items(makeq(case_sensitive=True))
self.assert_items_matched(results, ['caps path'])
results = self.lib.items(makeq(case_sensitive=False))
self.assert_items_matched(results, ['path item', 'caps path'])
# test platform-aware default sensitivity
with _common.system_mock('Darwin'):
q = makeq()
self.assertEqual(q.case_sensitive, True)
with _common.system_mock('Windows'):
q = makeq()
self.assertEqual(q.case_sensitive, False)
class IntQueryTest(unittest.TestCase, TestHelper):

View file

@ -25,7 +25,7 @@ try:
import gi
gi.require_version('Gst', '1.0')
GST_AVAILABLE = True
except ImportError, ValueError:
except (ImportError, ValueError):
GST_AVAILABLE = False
if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']):
@ -33,6 +33,11 @@ if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']):
else:
GAIN_PROG_AVAILABLE = False
if has_program('bs1770gain', ['--replaygain']):
LOUDNESS_PROG_AVAILABLE = True
else:
LOUDNESS_PROG_AVAILABLE = False
class ReplayGainCliTestBase(TestHelper):
@ -42,9 +47,18 @@ class ReplayGainCliTestBase(TestHelper):
try:
self.load_plugins('replaygain')
except:
self.teardown_beets()
self.unload_plugins()
raise
import sys
# store exception info so an error in teardown does not swallow it
exc_info = sys.exc_info()
try:
self.teardown_beets()
self.unload_plugins()
except:
# if load_plugins() failed then setup is incomplete and
# teardown operations may fail. In particular # {Item,Album}
# may not have the _original_types attribute in unload_plugins
pass
raise exc_info[1], None, exc_info[2]
self.config['replaygain']['backend'] = self.backend
album = self.add_album_fixture(2)
@ -123,6 +137,11 @@ class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase):
backend = u'command'
@unittest.skipIf(not LOUDNESS_PROG_AVAILABLE, 'bs1770gain cannot be found')
class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase):
backend = u'bs1770gain'
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

222
test/test_smartplaylist.py Normal file
View file

@ -0,0 +1,222 @@
# This file is part of beets.
# Copyright 2015, Bruno Cauet.
#
# 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.
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from os import path, remove
from tempfile import mkdtemp
from shutil import rmtree
from mock import Mock, MagicMock
from beetsplug.smartplaylist import SmartPlaylistPlugin
from beets.library import Item, Album, parse_query_string
from beets.dbcore import OrQuery
from beets.dbcore.query import NullSort, MultipleSort, FixedFieldSort
from beets.util import syspath
from beets.ui import UserError
from beets import config
from test._common import unittest
from test.helper import TestHelper
class SmartPlaylistTest(unittest.TestCase):
def test_build_queries(self):
spl = SmartPlaylistPlugin()
self.assertEqual(spl._matched_playlists, None)
self.assertEqual(spl._unmatched_playlists, None)
config['smartplaylist']['playlists'].set([])
spl.build_queries()
self.assertEqual(spl._matched_playlists, set())
self.assertEqual(spl._unmatched_playlists, set())
config['smartplaylist']['playlists'].set([
{'name': 'foo',
'query': 'FOO foo'},
{'name': 'bar',
'album_query': ['BAR bar1', 'BAR bar2']},
{'name': 'baz',
'query': 'BAZ baz',
'album_query': 'BAZ baz'}
])
spl.build_queries()
self.assertEqual(spl._matched_playlists, set())
foo_foo = parse_query_string('FOO foo', Item)
baz_baz = parse_query_string('BAZ baz', Item)
baz_baz2 = parse_query_string('BAZ baz', Album)
bar_bar = OrQuery((parse_query_string('BAR bar1', Album)[0],
parse_query_string('BAR bar2', Album)[0]))
self.assertEqual(spl._unmatched_playlists, set([
('foo', foo_foo, (None, None)),
('baz', baz_baz, baz_baz2),
('bar', (None, None), (bar_bar, None)),
]))
def test_build_queries_with_sorts(self):
spl = SmartPlaylistPlugin()
config['smartplaylist']['playlists'].set([
{'name': 'no_sort', 'query': 'foo'},
{'name': 'one_sort', 'query': 'foo year+'},
{'name': 'only_empty_sorts', 'query': ['foo', 'bar']},
{'name': 'one_non_empty_sort', 'query': ['foo year+', 'bar']},
{'name': 'multiple_sorts', 'query': ['foo year+', 'bar genre-']},
{'name': 'mixed', 'query': ['foo year+', 'bar', 'baz genre+ id-']}
])
spl.build_queries()
sorts = dict((name, sort)
for name, (_, sort), _ in spl._unmatched_playlists)
asseq = self.assertEqual # less cluttered code
S = FixedFieldSort # short cut since we're only dealing with this
asseq(sorts["no_sort"], NullSort())
asseq(sorts["one_sort"], S('year'))
asseq(sorts["only_empty_sorts"], None)
asseq(sorts["one_non_empty_sort"], S('year'))
asseq(sorts["multiple_sorts"],
MultipleSort([S('year'), S('genre', False)]))
asseq(sorts["mixed"],
MultipleSort([S('year'), S('genre'), S('id', False)]))
def test_matches(self):
spl = SmartPlaylistPlugin()
a = MagicMock(Album)
i = MagicMock(Item)
self.assertFalse(spl.matches(i, None, None))
self.assertFalse(spl.matches(a, None, None))
query = Mock()
query.match.side_effect = {i: True}.__getitem__
self.assertTrue(spl.matches(i, query, None))
self.assertFalse(spl.matches(a, query, None))
a_query = Mock()
a_query.match.side_effect = {a: True}.__getitem__
self.assertFalse(spl.matches(i, None, a_query))
self.assertTrue(spl.matches(a, None, a_query))
self.assertTrue(spl.matches(i, query, a_query))
self.assertTrue(spl.matches(a, query, a_query))
def test_db_changes(self):
spl = SmartPlaylistPlugin()
nones = None, None
pl1 = '1', ('q1', None), nones
pl2 = '2', ('q2', None), nones
pl3 = '3', ('q3', None), nones
spl._unmatched_playlists = set([pl1, pl2, pl3])
spl._matched_playlists = set()
spl.matches = Mock(return_value=False)
spl.db_change(None, "nothing")
self.assertEqual(spl._unmatched_playlists, set([pl1, pl2, pl3]))
self.assertEqual(spl._matched_playlists, set())
spl.matches.side_effect = lambda _, q, __: q == 'q3'
spl.db_change(None, "matches 3")
self.assertEqual(spl._unmatched_playlists, set([pl1, pl2]))
self.assertEqual(spl._matched_playlists, set([pl3]))
spl.matches.side_effect = lambda _, q, __: q == 'q1'
spl.db_change(None, "matches 3")
self.assertEqual(spl._matched_playlists, set([pl1, pl3]))
self.assertEqual(spl._unmatched_playlists, set([pl2]))
def test_playlist_update(self):
spl = SmartPlaylistPlugin()
i = Mock(path='/tagada.mp3')
i.evaluate_template.side_effect = lambda x, _: x
q = Mock()
a_q = Mock()
lib = Mock()
lib.items.return_value = [i]
lib.albums.return_value = []
pl = 'my_playlist.m3u', (q, None), (a_q, None)
spl._matched_playlists = [pl]
dir = mkdtemp()
config['smartplaylist']['relative_to'] = False
config['smartplaylist']['playlist_dir'] = dir
try:
spl.update_playlists(lib)
except Exception:
rmtree(dir)
raise
lib.items.assert_called_once_with(q, None)
lib.albums.assert_called_once_with(a_q, None)
m3u_filepath = path.join(dir, pl[0])
self.assertTrue(path.exists(m3u_filepath))
with open(syspath(m3u_filepath), 'r') as f:
content = f.read()
rmtree(dir)
self.assertEqual(content, "/tagada.mp3\n")
class SmartPlaylistCLITest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
self.item = self.add_item()
config['smartplaylist']['playlists'].set([
{'name': 'my_playlist.m3u',
'query': self.item.title},
{'name': 'all.m3u',
'query': ''}
])
config['smartplaylist']['playlist_dir'].set(self.temp_dir)
self.load_plugins('smartplaylist')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_splupdate(self):
with self.assertRaises(UserError):
self.run_with_output('splupdate', 'tagada')
self.run_with_output('splupdate', 'my_playlist')
m3u_path = path.join(self.temp_dir, 'my_playlist.m3u')
self.assertTrue(path.exists(m3u_path))
with open(m3u_path, 'r') as f:
self.assertEqual(f.read(), self.item.path + b"\n")
remove(m3u_path)
self.run_with_output('splupdate', 'my_playlist.m3u')
with open(m3u_path, 'r') as f:
self.assertEqual(f.read(), self.item.path + b"\n")
remove(m3u_path)
self.run_with_output('splupdate')
for name in ('my_playlist.m3u', 'all.m3u'):
with open(path.join(self.temp_dir, name), 'r') as f:
self.assertEqual(f.read(), self.item.path + b"\n")
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')

View file

@ -17,7 +17,7 @@ class ArgumentsMock(object):
def __init__(self, mode, show_failures):
self.mode = mode
self.show_failures = show_failures
self.verbose = True
self.verbose = 1
def _params(url):

View file

@ -44,7 +44,6 @@ class ThePluginTest(_common.TestCase):
def test_template_function_with_defaults(self):
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')

View file

@ -22,7 +22,9 @@ import shutil
import re
import subprocess
import platform
from copy import deepcopy
from mock import patch
from test import _common
from test._common import unittest
from test.helper import capture_stdout, has_program, TestHelper, control_stdin
@ -968,8 +970,45 @@ class ShowChangeTest(_common.TestCase):
self.items[0].title = u''
self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8')
msg = re.sub(r' +', ' ', self._show_change())
self.assertTrue(u'caf\xe9.mp3 -> the title' in msg
or u'caf.mp3 ->' in msg)
self.assertTrue(u'caf\xe9.mp3 -> the title' in msg or
u'caf.mp3 ->' in msg)
class SummarizeItemsTest(_common.TestCase):
def setUp(self):
super(SummarizeItemsTest, self).setUp()
item = library.Item()
item.bitrate = 4321
item.length = 10 * 60 + 54
item.format = "F"
self.item = item
fsize_mock = patch('beets.library.Item.try_filesize').start()
fsize_mock.return_value = 987
def test_summarize_item(self):
summary = commands.summarize_items([], True)
self.assertEqual(summary, "")
summary = commands.summarize_items([self.item], True)
self.assertEqual(summary, "F, 4kbps, 10:54, 987.0 B")
def test_summarize_items(self):
summary = commands.summarize_items([], False)
self.assertEqual(summary, "0 items")
summary = commands.summarize_items([self.item], False)
self.assertEqual(summary, "1 items, F, 4kbps, 10:54, 987.0 B")
i2 = deepcopy(self.item)
summary = commands.summarize_items([self.item, i2], False)
self.assertEqual(summary, "2 items, F, 4kbps, 21:48, 1.9 KB")
i2.format = "G"
summary = commands.summarize_items([self.item, i2], False)
self.assertEqual(summary, "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KB")
summary = commands.summarize_items([self.item, i2, i2], False)
self.assertEqual(summary, "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KB")
class PathFormatTest(_common.TestCase):
@ -1033,6 +1072,145 @@ class CompletionTest(_common.TestCase):
self.fail('test/test_completion.sh did not execute properly')
class CommonOptionsParserCliTest(unittest.TestCase, TestHelper):
"""Test CommonOptionsParser and formatting LibModel formatting on 'list'
command.
"""
def setUp(self):
self.setup_beets()
self.lib = library.Library(':memory:')
self.item = _common.item()
self.item.path = 'xxx/yyy'
self.lib.add(self.item)
self.lib.add_album([self.item])
def tearDown(self):
self.teardown_beets()
def test_base(self):
l = self.run_with_output('ls')
self.assertEqual(l, 'the artist - the album - the title\n')
l = self.run_with_output('ls', '-a')
self.assertEqual(l, 'the album artist - the album\n')
def test_path_option(self):
l = self.run_with_output('ls', '-p')
self.assertEqual(l, 'xxx/yyy\n')
l = self.run_with_output('ls', '-a', '-p')
self.assertEqual(l, 'xxx\n')
def test_format_option(self):
l = self.run_with_output('ls', '-f', '$artist')
self.assertEqual(l, 'the artist\n')
l = self.run_with_output('ls', '-a', '-f', '$albumartist')
self.assertEqual(l, 'the album artist\n')
def test_root_format_option(self):
l = self.run_with_output('--format-item', '$artist',
'--format-album', 'foo', 'ls')
self.assertEqual(l, 'the artist\n')
l = self.run_with_output('--format-item', 'foo',
'--format-album', '$albumartist', 'ls', '-a')
self.assertEqual(l, 'the album artist\n')
class CommonOptionsParserTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
def tearDown(self):
self.teardown_beets()
def test_album_option(self):
parser = ui.CommonOptionsParser()
self.assertFalse(parser._album_flags)
parser.add_album_option()
self.assertTrue(bool(parser._album_flags))
self.assertEqual(parser.parse_args([]), ({'album': None}, []))
self.assertEqual(parser.parse_args(['-a']), ({'album': True}, []))
self.assertEqual(parser.parse_args(['--album']), ({'album': True}, []))
def test_path_option(self):
parser = ui.CommonOptionsParser()
parser.add_path_option()
self.assertFalse(parser._album_flags)
config['format_item'].set('$foo')
self.assertEqual(parser.parse_args([]), ({'path': None}, []))
self.assertEqual(config['format_item'].get(unicode), u'$foo')
self.assertEqual(parser.parse_args(['-p']),
({'path': True, 'format': '$path'}, []))
self.assertEqual(parser.parse_args(['--path']),
({'path': True, 'format': '$path'}, []))
self.assertEqual(config['format_item'].get(unicode), '$path')
self.assertEqual(config['format_album'].get(unicode), '$path')
def test_format_option(self):
parser = ui.CommonOptionsParser()
parser.add_format_option()
self.assertFalse(parser._album_flags)
config['format_item'].set('$foo')
self.assertEqual(parser.parse_args([]), ({'format': None}, []))
self.assertEqual(config['format_item'].get(unicode), u'$foo')
self.assertEqual(parser.parse_args(['-f', '$bar']),
({'format': '$bar'}, []))
self.assertEqual(parser.parse_args(['--format', '$baz']),
({'format': '$baz'}, []))
self.assertEqual(config['format_item'].get(unicode), '$baz')
self.assertEqual(config['format_album'].get(unicode), '$baz')
def test_format_option_with_target(self):
with self.assertRaises(KeyError):
ui.CommonOptionsParser().add_format_option(target='thingy')
parser = ui.CommonOptionsParser()
parser.add_format_option(target='item')
config['format_item'].set('$item')
config['format_album'].set('$album')
self.assertEqual(parser.parse_args(['-f', '$bar']),
({'format': '$bar'}, []))
self.assertEqual(config['format_item'].get(unicode), '$bar')
self.assertEqual(config['format_album'].get(unicode), '$album')
def test_format_option_with_album(self):
parser = ui.CommonOptionsParser()
parser.add_album_option()
parser.add_format_option()
config['format_item'].set('$item')
config['format_album'].set('$album')
parser.parse_args(['-f', '$bar'])
self.assertEqual(config['format_item'].get(unicode), '$bar')
self.assertEqual(config['format_album'].get(unicode), '$album')
parser.parse_args(['-a', '-f', '$foo'])
self.assertEqual(config['format_item'].get(unicode), '$bar')
self.assertEqual(config['format_album'].get(unicode), '$foo')
parser.parse_args(['-f', '$foo2', '-a'])
self.assertEqual(config['format_album'].get(unicode), '$foo2')
def test_add_all_common_options(self):
parser = ui.CommonOptionsParser()
parser.add_all_common_options()
self.assertEqual(parser.parse_args([]),
({'album': None, 'path': None, 'format': None}, []))
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

184
test/test_util.py Normal file
View file

@ -0,0 +1,184 @@
# This file is part of beets.
# Copyright 2015, Adrian Sampson.
#
# 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.
"""Tests for base utils from the beets.util package.
"""
from __future__ import (division, absolute_import, print_function,
unicode_literals)
import sys
import re
import os
import subprocess
from mock import patch, Mock
from test._common import unittest
from test import _common
from beets import util
class UtilTest(unittest.TestCase):
def test_open_anything(self):
with _common.system_mock('Windows'):
self.assertEqual(util.open_anything(), 'start')
with _common.system_mock('Darwin'):
self.assertEqual(util.open_anything(), 'open')
with _common.system_mock('Tagada'):
self.assertEqual(util.open_anything(), 'xdg-open')
@patch('os.execlp')
@patch('beets.util.open_anything')
def test_interactive_open(self, mock_open, mock_execlp):
mock_open.return_value = 'tagada'
util.interactive_open('foo')
mock_execlp.assert_called_once_with('tagada', 'tagada', 'foo')
mock_execlp.reset_mock()
util.interactive_open('foo', 'bar')
mock_execlp.assert_called_once_with('bar', 'bar', 'foo')
def test_sanitize_unix_replaces_leading_dot(self):
with _common.platform_posix():
p = util.sanitize_path(u'one/.two/three')
self.assertFalse('.' in p)
def test_sanitize_windows_replaces_trailing_dot(self):
with _common.platform_windows():
p = util.sanitize_path(u'one/two./three')
self.assertFalse('.' in p)
def test_sanitize_windows_replaces_illegal_chars(self):
with _common.platform_windows():
p = util.sanitize_path(u':*?"<>|')
self.assertFalse(':' in p)
self.assertFalse('*' in p)
self.assertFalse('?' in p)
self.assertFalse('"' in p)
self.assertFalse('<' in p)
self.assertFalse('>' in p)
self.assertFalse('|' in p)
def test_sanitize_windows_replaces_trailing_space(self):
with _common.platform_windows():
p = util.sanitize_path(u'one/two /three')
self.assertFalse(' ' in p)
def test_sanitize_path_works_on_empty_string(self):
with _common.platform_posix():
p = util.sanitize_path(u'')
self.assertEqual(p, u'')
def test_sanitize_with_custom_replace_overrides_built_in_sub(self):
with _common.platform_posix():
p = util.sanitize_path(u'a/.?/b', [
(re.compile(r'foo'), u'bar'),
])
self.assertEqual(p, u'a/.?/b')
def test_sanitize_with_custom_replace_adds_replacements(self):
with _common.platform_posix():
p = util.sanitize_path(u'foo/bar', [
(re.compile(r'foo'), u'bar'),
])
self.assertEqual(p, u'bar/bar')
@unittest.skip('unimplemented: #359')
def test_sanitize_empty_component(self):
with _common.platform_posix():
p = util.sanitize_path(u'foo//bar', [
(re.compile(r'^$'), u'_'),
])
self.assertEqual(p, u'foo/_/bar')
@patch('beets.util.subprocess.Popen')
def test_command_output(self, mock_popen):
def popen_fail(*args, **kwargs):
m = Mock(returncode=1)
m.communicate.return_value = None, None
return m
mock_popen.side_effect = popen_fail
with self.assertRaises(subprocess.CalledProcessError) as exc_context:
util.command_output([b"taga", b"\xc3\xa9"])
self.assertEquals(exc_context.exception.returncode, 1)
self.assertEquals(exc_context.exception.cmd, b"taga \xc3\xa9")
class PathConversionTest(_common.TestCase):
def test_syspath_windows_format(self):
with _common.platform_windows():
path = os.path.join('a', 'b', 'c')
outpath = util.syspath(path)
self.assertTrue(isinstance(outpath, unicode))
self.assertTrue(outpath.startswith(u'\\\\?\\'))
def test_syspath_windows_format_unc_path(self):
# The \\?\ prefix on Windows behaves differently with UNC
# (network share) paths.
path = '\\\\server\\share\\file.mp3'
with _common.platform_windows():
outpath = util.syspath(path)
self.assertTrue(isinstance(outpath, unicode))
self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3')
def test_syspath_posix_unchanged(self):
with _common.platform_posix():
path = os.path.join('a', 'b', 'c')
outpath = util.syspath(path)
self.assertEqual(path, outpath)
def _windows_bytestring_path(self, path):
old_gfse = sys.getfilesystemencoding
sys.getfilesystemencoding = lambda: 'mbcs'
try:
with _common.platform_windows():
return util.bytestring_path(path)
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 PathTruncationTest(_common.TestCase):
def test_truncate_bytestring(self):
with _common.platform_posix():
p = util.truncate_path(b'abcde/fgh', 4)
self.assertEqual(p, b'abcd/fgh')
def test_truncate_unicode(self):
with _common.platform_posix():
p = util.truncate_path(u'abcde/fgh', 4)
self.assertEqual(p, u'abcd/fgh')
def test_truncate_preserves_extension(self):
with _common.platform_posix():
p = util.truncate_path(u'abcde/fgh.ext', 5)
self.assertEqual(p, u'abcde/f.ext')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')