This commit is contained in:
Rovanion Luckey 2014-09-14 20:14:42 +02:00
commit 725cb9b60a
34 changed files with 910 additions and 334 deletions

View file

@ -332,6 +332,11 @@ def _add_candidate(items, results, info):
"""
log.debug('Candidate: %s - %s' % (info.artist, info.album))
# Discard albums with zero tracks.
if not info.tracks:
log.debug('No tracks.')
return
# Don't duplicate.
if info.album_id in results:
log.debug('Duplicate.')

View file

@ -24,7 +24,7 @@ import collections
import beets
from beets.util.functemplate import Template
from .query import MatchQuery, build_sql
from .query import MatchQuery, NullSort
from .types import BASE_TYPE
@ -382,6 +382,8 @@ class Model(object):
self._check_db()
stored_obj = self._db._get(type(self), self.id)
assert stored_obj is not None, "object {0} not in DB".format(self.id)
self._values_fixed = {}
self._values_flex = {}
self.update(dict(stored_obj))
self.clear_dirty()
@ -735,7 +737,7 @@ class Database(object):
id INTEGER PRIMARY KEY,
entity_id INTEGER,
key TEXT,
value TEXT,
value NONE,
UNIQUE(entity_id, key) ON CONFLICT REPLACE);
CREATE INDEX IF NOT EXISTS {0}_by_entity
ON {0} (entity_id);
@ -743,20 +745,30 @@ class Database(object):
# Querying.
def _fetch(self, model_cls, query, sort_order=None):
def _fetch(self, model_cls, query, sort=None):
"""Fetch the objects of type `model_cls` matching the given
query. The query may be given as a string, string sequence, a
Query object, or None (to fetch everything). If provided,
`sort_order` is either a SQLite ORDER BY clause for sorting or a
Sort object.
"""
Query object, or None (to fetch everything). `sort` is an
optional Sort object.
"""
where, subvals = query.clause()
sort = sort or NullSort()
order_by = sort.order_clause()
sql, subvals, query, sort = build_sql(model_cls, query, sort_order)
sql = ("SELECT * FROM {0} WHERE {1} {2}").format(
model_cls._table,
where or '1',
"ORDER BY {0}".format(order_by) if order_by else '',
)
with self.transaction() as tx:
rows = tx.query(sql, subvals)
return Results(model_cls, rows, self, query, sort)
return Results(
model_cls, rows, self,
None if where else query, # Slow query component.
sort if sort.is_slow() else None, # Slow sort component.
)
def _get(self, model_cls, id):
"""Get a Model object by its id or None if the id does not

View file

@ -18,6 +18,10 @@ import re
from operator import attrgetter
from beets import util
from datetime import datetime, timedelta
from collections import namedtuple
SortedQuery = namedtuple('SortedQuery', ['query', 'sort'])
class Query(object):
@ -204,7 +208,9 @@ class NumericQuery(FieldQuery):
self.rangemax = self._convert(parts[1])
def match(self, item):
value = getattr(item, self.field)
if self.field not in item:
return False
value = item[self.field]
if isinstance(value, basestring):
value = self._convert(value)
@ -500,57 +506,49 @@ class DateQuery(FieldQuery):
return clause, subvals
class Sort(object):
"""An abstract class representing a sort operation for a query into the
item database.
"""
def select_clause(self):
""" Generates a select sql fragment if the sort operation requires one,
an empty string otherwise.
"""
return ""
# Sorting.
def union_clause(self):
""" Generates a union sql fragment if the sort operation requires one,
an empty string otherwise.
"""
return ""
class Sort(object):
"""An abstract class representing a sort operation for a query into
the item database.
"""
def order_clause(self):
"""Generates a sql fragment to be use in a ORDER BY clause or None if
it's a slow query.
"""Generates a SQL fragment to be used in a ORDER BY clause, or
None if no fragment is used (i.e., this is a slow sort).
"""
return None
def sort(self, items):
"""Return a key function that can be used with the list.sort() method.
Meant to be used with slow sort, it must be implemented even for sort
that can be done with sql, as they might be used in conjunction with
slow sort.
"""Sort the list of objects and return a list.
"""
return sorted(items, key=lambda x: x)
return sorted(items)
def is_slow(self):
"""Indicate whether this query is *slow*, meaning that it cannot
be executed in SQL and must be executed in Python.
"""
return False
class MultipleSort(Sort):
"""Sort class that combines several sort criteria.
This implementation tries to implement as many sort operation in sql,
falling back to python sort only when necessary.
"""Sort that encapsulates multiple sub-sorts.
"""
def __init__(self):
self.sorts = []
def __init__(self, sorts=None):
self.sorts = sorts or []
def add_criteria(self, sort):
def add_sort(self, sort):
self.sorts.append(sort)
def _sql_sorts(self):
""" Returns the list of sort for which sql can be used
"""Return the list of sub-sorts for which we can be (at least
partially) fast.
A contiguous suffix of fast (SQL-capable) sub-sorts are
executable in SQL. The remaining, even if they are fast
independently, must be executed slowly.
"""
# with several Sort, we can use SQL sorting only if there is only
# SQL-capable Sort or if the list ends with SQl-capable Sort.
sql_sorts = []
for sort in reversed(self.sorts):
if not sort.order_clause() is None:
@ -560,34 +558,13 @@ class MultipleSort(Sort):
sql_sorts.reverse()
return sql_sorts
def select_clause(self):
sql_sorts = self._sql_sorts()
select_strings = []
for sort in sql_sorts:
select = sort.select_clause()
if select:
select_strings.append(select)
select_string = ",".join(select_strings)
return select_string
def union_clause(self):
sql_sorts = self._sql_sorts()
union_strings = []
for sort in sql_sorts:
union = sort.union_clause()
union_strings.append(union)
return "".join(union_strings)
def order_clause(self):
sql_sorts = self._sql_sorts()
order_strings = []
for sort in sql_sorts:
for sort in self._sql_sorts():
order = sort.order_clause()
order_strings.append(order)
return ",".join(order_strings)
return ", ".join(order_strings)
def is_slow(self):
for sort in self.sorts:
@ -611,144 +588,72 @@ class MultipleSort(Sort):
items = sort.sort(items)
return items
def __repr__(self):
return u'MultipleSort({0})'.format(repr(self.sorts))
class FlexFieldSort(Sort):
"""Sort object to sort on a flexible attribute field
class FieldSort(Sort):
"""An abstract sort criterion that orders by a specific field (of
any kind).
"""
def __init__(self, model_cls, field, is_ascending):
self.model_cls = model_cls
def __init__(self, field, ascending=True):
self.field = field
self.is_ascending = is_ascending
self.ascending = ascending
def select_clause(self):
""" Return a select sql fragment.
"""
return "sort_flexattr{0!s}.value as flex_{0!s} ".format(self.field)
def sort(self, objs):
# TODO: Conversion and null-detection here. In Python 3,
# comparisons with None fail. We should also support flexible
# attributes with different types without falling over.
return sorted(objs, key=attrgetter(self.field),
reverse=not self.ascending)
def union_clause(self):
""" Returns an union sql fragment.
"""
union = ("LEFT JOIN {flextable} as sort_flexattr{index!s} "
"ON {table}.id = sort_flexattr{index!s}.entity_id "
"AND sort_flexattr{index!s}.key='{flexattr}' ").format(
flextable=self.model_cls._flex_table,
table=self.model_cls._table,
index=self.field, flexattr=self.field)
return union
def order_clause(self):
""" Returns an order sql fragment.
"""
order = "ASC" if self.is_ascending else "DESC"
return "flex_{0} {1} ".format(self.field, order)
def sort(self, items):
return sorted(items, key=attrgetter(self.field),
reverse=(not self.is_ascending))
def __repr__(self):
return u'<{0}: {1}{2}>'.format(
type(self).__name__,
self.field,
'+' if self.ascending else '-',
)
class FixedFieldSort(Sort):
"""Sort object to sort on a fixed field
class FixedFieldSort(FieldSort):
"""Sort object to sort on a fixed field.
"""
def __init__(self, field, is_ascending=True):
self.field = field
self.is_ascending = is_ascending
def order_clause(self):
order = "ASC" if self.is_ascending else "DESC"
order = "ASC" if self.ascending else "DESC"
return "{0} {1}".format(self.field, order)
def sort(self, items):
return sorted(items, key=attrgetter(self.field),
reverse=(not self.is_ascending))
class SmartArtistSort(Sort):
""" Sort Album or Item on artist sort fields, defaulting back on
artist field if the sort specific field is empty.
"""Sort by artist (either album artist or track artist),
prioritizing the sort field over the raw field.
"""
def __init__(self, model_cls, is_ascending=True):
self.model_cls = model_cls
self.is_ascending = is_ascending
def select_clause(self):
return ""
def union_clause(self):
return ""
def order_clause(self):
order = "ASC" if self.is_ascending else "DESC"
if 'albumartist_sort' in self.model_cls._fields:
exp1 = 'albumartist_sort'
exp2 = 'albumartist'
elif 'artist_sort' in self.model_cls_fields:
exp1 = 'artist_sort'
exp2 = 'artist'
if 'albumartist' in self.model_cls._fields:
field = 'albumartist'
else:
return ""
order_str = ('(CASE {0} WHEN NULL THEN {1} '
'WHEN "" THEN {1} '
'ELSE {0} END) {2} ').format(exp1, exp2, order)
return order_str
field = 'artist'
return ('(CASE {0}_sort WHEN NULL THEN {0} '
'WHEN "" THEN {0} '
'ELSE {0}_sort END) {1}').format(field, order)
class ComputedFieldSort(Sort):
def __init__(self, model_cls, field, is_ascending=True):
self.is_ascending = is_ascending
self.field = field
self._getters = model_cls._getters()
class SlowFieldSort(FieldSort):
"""A sort criterion by some model field other than a fixed field:
i.e., a computed or flexible field.
"""
def is_slow(self):
return True
def sort(self, items):
return sorted(items, key=lambda x: self._getters[self.field](x),
reverse=(not self.is_ascending))
special_sorts = {'smartartist': SmartArtistSort}
class NullSort(Sort):
"""No sorting. Leave results unsorted."""
def sort(items):
return items
def build_sql(model_cls, query, sort):
""" Generate a sql statement (and the values that must be injected into it)
from a query, sort and a model class. Query and sort objects are returned
only for slow query and slow sort operation.
"""
where, subvals = query.clause()
if where is not None:
query = None
if not sort:
sort_select = ""
sort_union = ""
sort_order = ""
sort = None
elif isinstance(sort, basestring):
sort_select = ""
sort_union = ""
sort_order = " ORDER BY {0}".format(sort) \
if sort else ""
sort = None
elif isinstance(sort, Sort):
select_clause = sort.select_clause()
sort_select = " ,{0} ".format(select_clause) \
if select_clause else ""
sort_union = sort.union_clause()
order_clause = sort.order_clause()
sort_order = " ORDER BY {0}".format(order_clause) \
if order_clause else ""
if sort.is_slow():
sort = None
sql = ("SELECT {table}.* {sort_select} FROM {table} {sort_union} WHERE "
"{query_clause} {sort_order}").format(
sort_select=sort_select,
sort_union=sort_union,
table=model_cls._table,
query_clause=where or '1',
sort_order=sort_order
)
return sql, subvals, query, sort
def __nonzero__(self):
return False

View file

@ -124,30 +124,36 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
def construct_sort_part(model_cls, part):
""" Creates a Sort object from a single criteria. Returns a `Sort` instance.
"""Create a `Sort` from a single string criterion.
`model_cls` is the `Model` being queried. `part` is a single string
ending in ``+`` or ``-`` indicating the sort.
"""
sort = None
assert part, "part must be a field name and + or -"
field = part[:-1]
is_ascending = (part[-1] == '+')
assert field, "field is missing"
direction = part[-1]
assert direction in ('+', '-'), "part must end with + or -"
is_ascending = direction == '+'
if field in model_cls._fields:
sort = query.FixedFieldSort(field, is_ascending)
elif field in model_cls._getters():
# Computed field, all following fields must use the slow path.
sort = query.ComputedFieldSort(model_cls, field, is_ascending)
elif field in query.special_sorts:
sort = query.special_sorts[field](model_cls, is_ascending)
elif field == 'smartartist':
# Special case for smart artist sort.
sort = query.SmartArtistSort(model_cls, is_ascending)
else:
# Neither fixed nor computed : must be a flex attr.
sort = query.FlexFieldSort(model_cls, field, is_ascending)
# Flexible or computed.
sort = query.SlowFieldSort(field, is_ascending)
return sort
def sort_from_strings(model_cls, sort_parts):
"""Creates a Sort object from a list of sort criteria strings.
"""Create a `Sort` from a list of sort criteria (strings).
"""
if not sort_parts:
return None
sort = query.MultipleSort()
for part in sort_parts:
sort.add_criteria(construct_sort_part(model_cls, part))
return sort
return query.NullSort()
else:
sort = query.MultipleSort()
for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part))
return sort

View file

@ -62,6 +62,8 @@ class PathQuery(dbcore.FieldQuery):
class DateType(types.Type):
# TODO representation should be `datetime` object
# TODO distinguish beetween date and time types
sql = u'REAL'
query = dbcore.query.DateQuery
null = 0.0
@ -139,16 +141,6 @@ class MusicalKey(types.String):
PF_KEY_DEFAULT = 'default'
# A little SQL utility.
def _orelse(exp1, exp2):
"""Generates an SQLite expression that evaluates to exp1 if exp1 is
non-null and non-empty or exp2 otherwise.
"""
return ("""(CASE {0} WHEN NULL THEN {1}
WHEN "" THEN {1}
ELSE {0} END)""").format(exp1, exp2)
# Exceptions.
class FileOperationError(Exception):

View file

@ -1122,13 +1122,15 @@ class DateField(MediaField):
"""
if year is None:
self.__delete__(mediafile)
date = [year]
return
date = [u'{0:04d}'.format(int(year))]
if month:
date.append(month)
date.append(u'{0:02d}'.format(int(month)))
if month and day:
date.append(day)
date.append(u'{0:02d}'.format(int(day)))
date = map(unicode, date)
super(DateField, self).__set__(mediafile, '-'.join(date))
super(DateField, self).__set__(mediafile, u'-'.join(date))
if hasattr(self, '_year_field'):
self._year_field.__set__(mediafile, year)

View file

@ -31,6 +31,14 @@ LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
log = logging.getLogger('beets')
class PluginConflictException(Exception):
"""Indicates that the services provided by one plugin conflict with
those of another.
For example two plugins may define different types for flexible fields.
"""
# Managing the plugins themselves.
class BeetsPlugin(object):
@ -136,7 +144,7 @@ class BeetsPlugin(object):
>>> @MyPlugin.listen("imported")
>>> def importListener(**kwargs):
>>> pass
... pass
"""
def helper(func):
if cls.listeners is None:
@ -247,6 +255,22 @@ def queries():
return out
def types(model_cls):
# Gives us `item_types` and `album_types`
attr_name = '{0}_types'.format(model_cls.__name__.lower())
types = {}
for plugin in find_plugins():
plugin_types = getattr(plugin, attr_name, {})
for field in plugin_types:
if field in types:
raise PluginConflictException(
u'Plugin {0} defines flexible field {1} '
'which has already been defined.'
.format(plugin.name,))
types.update(plugin_types)
return types
def track_distance(item, info):
"""Gets the track distance calculated by all loaded plugins.
Returns a Distance object.

View file

@ -679,16 +679,6 @@ class SubcommandsOptionParser(optparse.OptionParser):
# Super constructor.
optparse.OptionParser.__init__(self, *args, **kwargs)
self.add_option('-l', '--library', dest='library',
help='library database file to use')
self.add_option('-d', '--directory', dest='directory',
help="destination music directory")
self.add_option('-v', '--verbose', dest='verbose', action='store_true',
help='print debugging information')
self.add_option('-c', '--config', dest='config',
help='path to configuration file')
self.add_option('-h', '--help', dest='help', action='store_true',
help='how this help message and exit')
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
@ -774,6 +764,8 @@ class SubcommandsOptionParser(optparse.OptionParser):
# Force the help command
if options.help:
subargs = ['help']
elif options.version:
subargs = ['version']
return options, subargs
def parse_subcommand(self, args):
@ -838,7 +830,7 @@ def vararg_callback(option, opt_str, value, parser):
def _load_plugins(config):
"""Load the plugins specified in the configuration.
"""
paths = config['pluginpath'].get(confit.EnsureStringList())
paths = config['pluginpath'].get(confit.StrSeq(split=False))
paths = map(util.normpath, paths)
import beetsplug
@ -877,6 +869,8 @@ def _setup(options, lib=None):
if lib is None:
lib = _open_library(config)
plugins.send("library_opened", lib=lib)
library.Item._types = plugins.types(library.Item)
library.Album._types = plugins.types(library.Album)
return subcommands, plugins, lib
@ -884,7 +878,6 @@ def _setup(options, lib=None):
def _configure(options):
"""Amend the global configuration object with command line options.
"""
# Add any additional config files specified with --config. This
# special handling lets specified plugins get loaded before we
# finish parsing the command line.
@ -941,16 +934,27 @@ def _raw_main(args, lib=None):
"""A helper function for `main` without top-level exception
handling.
"""
parser = SubcommandsOptionParser()
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',
help='print debugging information')
parser.add_option('-c', '--config', dest='config',
help='path to configuration file')
parser.add_option('-h', '--help', dest='help', action='store_true',
help='how this help message and exit')
parser.add_option('--version', dest='version', action='store_true',
help=optparse.SUPPRESS_HELP)
options, subargs = parser.parse_global_options(args)
subcommands, plugins, lib = _setup(options, lib)
parser.add_subcommand(*subcommands)
subcommand, suboptions, subargs = parser.parse_subcommand(subargs)
subcommand, suboptions, subargs = parser.parse_subcommand(subargs)
subcommand.func(lib, suboptions, subargs)
plugins.send('cli_exit', lib=lib)

View file

@ -220,6 +220,9 @@ class ConfigView(object):
"""
self.set({key: value})
def __contains__(self, key):
return self[key].exists()
def set_args(self, namespace):
"""Overlay parsed command-line arguments, generated by a library
like argparse or optparse, onto this view's value.
@ -1052,41 +1055,43 @@ class Choice(Template):
class StrSeq(Template):
"""A template for values that are lists of strings.
Validates both actual YAML string lists and whitespace-separated
strings.
Validates both actual YAML string lists and single strings. Strings
can optionally be split on whitespace.
"""
def __init__(self, split=True):
"""Create a new template.
`split` indicates whether, when the underlying value is a single
string, it should be split on whitespace. Otherwise, the
resulting value is a list containing a single string.
"""
super(StrSeq, self).__init__()
self.split = split
def convert(self, value, view):
if isinstance(value, bytes):
value = value.decode('utf8', 'ignore')
if isinstance(value, STRING):
return value.split()
else:
try:
value = list(value)
except TypeError:
self.fail('must be a whitespace-separated string or a list',
view, True)
if all(isinstance(x, BASESTRING) for x in value):
return value
if self.split:
return value.split()
else:
return [value]
try:
value = list(value)
except TypeError:
self.fail('must be a whitespace-separated string or a list',
view, True)
def convert(x):
if isinstance(x, unicode):
return x
elif isinstance(x, BASESTRING):
return x.decode('utf8', 'ignore')
else:
self.fail('must be a list of strings', view, True)
class EnsureStringList(Template):
"""Always return a list of strings.
The raw value may either be a single string or a list of strings.
Otherwise a type error is raised. For single strings a singleton
list is returned.
"""
def convert(self, paths, view):
if isinstance(paths, basestring):
paths = [paths]
if not isinstance(paths, list) or \
not all(map(lambda p: isinstance(p, basestring), paths)):
self.fail(u'must be string or a list of strings', view, True)
return paths
return map(convert, value)
class Filename(Template):

View file

@ -37,6 +37,8 @@ ALIASES = {
u'vorbis': u'ogg',
}
LOSSLESS_FORMATS = ['ape', 'flac', 'alac', 'wav']
def replace_ext(path, ext):
"""Return the path with its extension replaced by `ext`.
@ -128,6 +130,9 @@ def should_transcode(item, format):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
if config['convert']['never_convert_lossy_files'] and \
not (item.format.lower() in LOSSLESS_FORMATS):
return False
maxbr = config['convert']['max_bitrate'].get(int)
return format.lower() != item.format.lower() or \
item.bitrate >= 1000 * maxbr
@ -145,10 +150,9 @@ def convert_item(dest_dir, keep_new, path_formats, format, pretend=False):
# back to its old path or transcode it to a new path.
if keep_new:
original = dest
converted = replace_ext(item.path, ext)
converted = item.path
else:
original = item.path
dest = replace_ext(dest, ext)
converted = dest
# Ensure that only one thread tries to create directories at a
@ -176,7 +180,13 @@ def convert_item(dest_dir, keep_new, path_formats, format, pretend=False):
)
util.move(item.path, original)
if not should_transcode(item, format):
if should_transcode(item, format):
converted = replace_ext(converted, ext)
try:
encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
continue
else:
if pretend:
log.info(u'cp {0} {1}'.format(
util.displayable_path(original),
@ -188,11 +198,6 @@ def convert_item(dest_dir, keep_new, path_formats, format, pretend=False):
util.displayable_path(item.path))
)
util.copy(original, converted)
else:
try:
encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
continue
if pretend:
continue
@ -212,7 +217,12 @@ def convert_item(dest_dir, keep_new, path_formats, format, pretend=False):
if album and album.artpath:
embed_item(item, album.artpath, itempath=converted)
plugins.send('after_convert', item=item, dest=dest, keepnew=keep_new)
if keep_new:
plugins.send('after_convert', item=item,
dest=dest, keepnew=True)
else:
plugins.send('after_convert', item=item,
dest=converted, keepnew=False)
def convert_on_import(lib, item):
@ -259,7 +269,7 @@ def convert_func(lib, opts, args):
if not pretend:
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
if not ui.input_yn("Convert? (Y/n)"):
if not (opts.yes or ui.input_yn("Convert? (Y/n)")):
return
if opts.album:
@ -308,6 +318,7 @@ class ConvertPlugin(BeetsPlugin):
u'quiet': False,
u'embed': True,
u'paths': {},
u'never_convert_lossy_files': False,
})
self.import_stages = [self.auto_convert]
@ -327,6 +338,8 @@ class ConvertPlugin(BeetsPlugin):
help='set the destination directory')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help='set the destination directory')
cmd.parser.add_option('-y', '--yes', action='store', dest='yes',
help='do not ask for confirmation')
cmd.func = convert_func
return [cmd]

View file

@ -23,6 +23,7 @@ from string import Template
import subprocess
from beets import util, config, plugins, ui
from beets.dbcore import types
import pyechonest
import pyechonest.song
import pyechonest.track
@ -38,7 +39,9 @@ DEVNULL = open(os.devnull, 'wb')
ALLOWED_FORMATS = ('MP3', 'OGG', 'AAC')
UPLOAD_MAX_SIZE = 50 * 1024 * 1024
# The attributes we can import and where to store them in beets fields.
# Maps attribute names from echonest to their field names in beets.
# The attributes are retrieved from a songs `audio_summary`. See:
# http://echonest.github.io/pyechonest/song.html#pyechonest.song.profile
ATTRIBUTES = {
'energy': 'energy',
'liveness': 'liveness',
@ -49,6 +52,16 @@ ATTRIBUTES = {
'tempo': 'bpm',
}
# Types for the flexible fields added by `ATTRIBUTES`
FIELD_TYPES = {
'energy': types.FLOAT,
'liveness': types.FLOAT,
'speechiness': types.FLOAT,
'acousticness': types.FLOAT,
'danceability': types.FLOAT,
'valence': types.FLOAT,
}
MUSICAL_SCALE = ['C', 'C#', 'D', 'D#', 'E' 'F',
'F#', 'G', 'G#', 'A', 'A#', 'B']
@ -104,6 +117,9 @@ def similar(lib, src_item, threshold=0.15, fmt='${difference}: ${path}'):
class EchonestMetadataPlugin(plugins.BeetsPlugin):
item_types = FIELD_TYPES
def __init__(self):
super(EchonestMetadataPlugin, self).__init__()
self.config.add({

View file

@ -234,6 +234,7 @@ def art_for_album(album, paths, maxwidth=None, local_only=False):
# Local art.
cover_names = config['fetchart']['cover_names'].as_str_seq()
cover_names = map(util.bytestring_path, cover_names)
cautious = config['fetchart']['cautious'].get(bool)
if paths:
for path in paths:

View file

@ -15,7 +15,8 @@
"""Gets genres for imported music based on Last.fm tags.
Uses a provided whitelist file to determine which tags are valid genres.
The included (default) genre list was produced by scraping Wikipedia.
The included (default) genre list was originally produced by scraping Wikipedia
and has been edited to remove some questionable entries.
The scraper script used is available here:
https://gist.github.com/1241307
"""

View file

@ -26,7 +26,6 @@ ambient house
ambient music
americana
anarcho punk
anime music
anti-folk
apala
ape haters
@ -51,7 +50,6 @@ avant-garde music
axé
bac-bal
bachata
background music
baggy
baila
baile funk
@ -449,7 +447,6 @@ emocore
emotronic
enka
eremwu eu
essential rock
ethereal pop
ethereal wave
euro
@ -1047,7 +1044,6 @@ new york blues
new york house
newgrass
nganja
niche
nightcore
nintendocore
nisiótika
@ -1310,7 +1306,6 @@ sica
siguiriyas
silat
sinawi
singer-songwriter
situational
ska
ska punk
@ -1338,7 +1333,6 @@ soul
soul blues
soul jazz
soul music
soundtrack
southern gospel
southern harmony
southern hip hop
@ -1472,7 +1466,6 @@ vaudeville
venezuela
verbunkos
verismo
video game music
viking metal
villanella
virelai

View file

@ -25,6 +25,7 @@ from beets import config
from beets import plugins
from beets import library
from beets.util import displayable_path
from beets.dbcore import types
log = logging.getLogger('beets')
@ -308,6 +309,14 @@ class MPDStats(object):
class MPDStatsPlugin(plugins.BeetsPlugin):
item_types = {
'play_count': types.INTEGER,
'skip_count': types.INTEGER,
'last_played': library.Date(),
'rating': types.FLOAT,
}
def __init__(self):
super(MPDStatsPlugin, self).__init__()
self.config.add({

View file

@ -113,7 +113,8 @@ class PlayPlugin(BeetsPlugin):
config['play'].add({
'command': None,
'use_folders': False
'use_folders': False,
'relative_to': None,
})
def commands(self):

42
beetsplug/types.py Normal file
View file

@ -0,0 +1,42 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# 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 beets.plugins import BeetsPlugin
from beets.dbcore import types
from beets.util.confit import ConfigValueError
from beets import library
class TypesPlugin(BeetsPlugin):
@property
def item_types(self):
if not self.config.exists():
return {}
mytypes = {}
for key, value in self.config.items():
if value.get() == 'int':
mytypes[key] = types.INTEGER
elif value.get() == 'float':
mytypes[key] = types.FLOAT
elif value.get() == 'bool':
mytypes[key] = types.BOOLEAN
elif value.get() == 'date':
mytypes[key] = library.DateType()
else:
raise ConfigValueError(
u"unknown type '{0}' for the '{1}' field"
.format(value, key))
return mytypes

View file

@ -14,6 +14,10 @@ Features:
``--summarize`` option.
* :doc:`/plugins/mbcollection`: A new option lets you automatically update
your collection on import. Thanks to Olin Gay.
* :doc:`/plugins/convert`: A new ``never_convert_lossy_files`` option can
prevent lossy transcoding. Thanks to Simon Kohlmeyer.
* :doc:`/plugins/convert`: A new ``--yes`` command-line flag skips the
confirmation.
Fixes:
@ -41,6 +45,10 @@ Fixes:
to Bombardment.
* :doc:`/plugins/play`: Add a ``relative_to`` config option. Thanks to
BrainDamage.
* Fix a crash when a MusicBrainz release has zero tracks.
* The ``--version`` flag now works as an alias for the ``version`` command.
* :doc:`/plugins/lastgenre`: Remove some unhelpful genres from the default
whitelist. Thanks to gwern.
.. _discogs_client: https://github.com/discogs/discogs_client

View file

@ -397,3 +397,37 @@ plugin will be used if we issue a command like ``beet ls @something`` or
return {
'@': ExactMatchQuery
}
Flexible Field Types
^^^^^^^^^^^^^^^^^^^^
If your plugin uses flexible fields to store numbers or other
non-string values you can specify the types of those fields. A rating
plugin, for example might look like this. ::
from beets.plugins import BeetsPlugin
from beets.dbcore import types
class RatingPlugin(BeetsPlugin):
item_types = {'rating': types.INTEGER}
@property
def album_types(self):
return {'rating': types.INTEGER}
A plugin may define two attributes, `item_types` and `album_types`.
Each of those attributes is a dictionary mapping a flexible field name
to a type instance. You can find the built-in types in the
`beets.dbcore.types` and `beets.library` modules or implement your own
ones.
Specifying types has the following advantages.
* The flexible field accessors ``item['my_field']`` return the
specified type instead of a string.
* Users can use advanced queries (like :ref:`ranges <numericquery>`)
from the command line.
* User input for flexible fields may be validated.

View file

@ -139,15 +139,14 @@ it's helpful to run on the "bleeding edge". To run the latest source:
- Use ``pip`` to install the latest snapshot tarball: just type
``pip install https://github.com/sampsyo/beets/tarball/master``.
- Grab the source using Mercurial
(``hg clone https://bitbucket.org/adrian/beets``) or git
(``git clone https://github.com/sampsyo/beets.git``). Then
- Grab the source using Git:
``git clone https://github.com/sampsyo/beets.git``. Then
``cd beets`` and type ``python setup.py install``.
- Use ``pip`` to install an "editable" version of beets based on an
automatic source checkout. For example, run
``pip install -e hg+https://bitbucket.org/adrian/beets#egg=beets``
to clone beets from BitBucket using Mercurial and install it,
allowing you to modify the source in-place to try out changes.
``pip install -e git+https://github.com/sampsyo/beets#egg=beets``
to clone beets and install it, allowing you to modify the source
in-place to try out changes.
More details about the beets source are available on the :doc:`developer documentation </dev/index>`
pages.

View file

@ -20,19 +20,20 @@ transcode the audio, so you might want to install it.
Usage
-----
To convert a part of your collection, run ``beet convert QUERY``. This
will display all items matching ``QUERY`` and ask you for confirmation before
starting the conversion. The command will then transcode all the
matching files to the destination directory given by the ``-d``
(``--dest``) option or the ``dest`` configuration. The path layout
mirrors that of your library, but it may be customized through the
``paths`` configuration.
To convert a part of your collection, run ``beet convert QUERY``. The
command will transcode all the files matching the query to the
destination directory given by the ``-d`` (``--dest``) option or the
``dest`` configuration. The path layout mirrors that of your library,
but it may be customized through the ``paths`` configuration.
The plugin uses a command-line program to transcode the audio. With the
``-f`` (``--format``) option you can choose the transcoding command
and customize the available commands
:ref:`through the configuration <convert-format-config>`.
Unless the ``-y`` (``--yes``) flag is set, the command will list all
the items to be converted and ask for your confirmation.
The ``-a`` (or ``--album``) option causes the command
to match albums instead of tracks.
@ -67,6 +68,10 @@ The plugin offers several configuration options, all of which live under the
adding them to your library.
* ``quiet`` mode prevents the plugin from announcing every file it processes.
Default: false.
* ``never_convert_lossy_files`` means that lossy codecs, such as mp3, ogg vorbis,
etc, are never converted, as converting lossy files to other lossy codecs will
decrease quality further. If set to true, lossy files are always copied.
Default: false
* ``paths`` lets you specify the directory structure and naming scheme for the
converted files. Use the same format as the top-level ``paths`` section (see
:ref:`path-format-config`). By default, the plugin reuses your top-level

View file

@ -62,6 +62,7 @@ by typing ``beet version``.
importadded
bpm
spotify
types
Autotagger Extensions
---------------------
@ -136,7 +137,8 @@ Miscellaneous
* :doc:`info`: Print music files' tags to the console.
* :doc:`missing`: List missing tracks.
* :doc:`duplicates`: List duplicate tracks or albums.
* :doc:`spotify`: Create Spotify playlists from the Beets library
* :doc:`spotify`: Create Spotify playlists from the Beets library.
* :doc:`types`: Declare types for flexible attributes.
.. _MPD: http://www.musicpd.org/
.. _MPD clients: http://mpd.wikia.com/wiki/Clients

View file

@ -21,7 +21,7 @@ your ``plugins`` line in :doc:`config file </reference/config>`.
The plugin chooses genres based on a *whitelist*, meaning that only certain
tags can be considered genres. This way, tags like "my favorite music" or "seen
live" won't be considered genres. The plugin ships with a fairly extensive
internal whitelist, but you can set your own in the config file using the
`internal whitelist`_, but you can set your own in the config file using the
``whitelist`` configuration value::
lastgenre:
@ -36,6 +36,7 @@ Wikipedia`_.
.. _pip: http://www.pip-installer.org/
.. _pylast: http://code.google.com/p/pylast/
.. _script that scrapes Wikipedia: https://gist.github.com/1241307
.. _internal whitelist: https://raw.githubusercontent.com/sampsyo/beets/master/beetsplug/lastgenre/genres.txt
By default, beets will always fetch new genres, even if the files already have
once. To instead leave genres in place in when they pass the whitelist, set

17
docs/plugins/types.rst Normal file
View file

@ -0,0 +1,17 @@
Types Plugin
============
The ``types`` plugin lets you declare types for attributes you use in your
library. For example, you can declare that a ``rating`` field is numeric so
that you can query it with ranges---which isn't possible when the field is
considered a string, which is the default.
Enable the plugin as described in :doc:`/plugins/index` and then add a
``types`` section to your :doc:`configuration file </reference/config>`. The
configuration section should map field name to one of ``int``, ``float``,
``bool``, or ``date``.
Here's an example:
types:
rating: int

View file

@ -174,9 +174,8 @@ list
Want to search for "Gronlandic Edit" by of Montreal? Try ``beet list
gronlandic``. Maybe you want to see everything released in 2009 with
"vegetables" in the title? Try ``beet list year:2009 title:vegetables``. You
can also specify the order used when outputting the results (Read more in
:doc:`query`.)
"vegetables" in the title? Try ``beet list year:2009 title:vegetables``. You
can also specify the sort order. (Read more in :doc:`query`.)
You can use the ``-a`` switch to search for albums instead of individual items.
In this case, the queries you use are restricted to album-level fields: for

View file

@ -43,7 +43,7 @@ from enum import Enum
import beets
from beets import config
import beets.plugins
from beets.library import Library, Item
from beets.library import Library, Item, Album
from beets import importer
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.mediafile import MediaFile
@ -168,18 +168,24 @@ class TestHelper(object):
Similar setting a list of plugins in the configuration. Make
sure you call ``unload_plugins()`` afterwards.
"""
# FIXME this should eventually be handled by a plugin manager
beets.config['plugins'] = plugins
beets.plugins.load_plugins(plugins)
beets.plugins.find_plugins()
Item._types = beets.plugins.types(Item)
Album._types = beets.plugins.types(Album)
def unload_plugins(self):
"""Unload all plugins and remove the from the configuration.
"""
# FIXME this should eventually be handled by a plugin manager
beets.config['plugins'] = []
for plugin in beets.plugins._classes:
plugin.listeners = None
beets.plugins._classes = set()
beets.plugins._instances = {}
Item._types = {}
Album._types = {}
def create_importer(self, item_count=1, album_count=1):
"""Create files to import and return corresponding session.
@ -230,9 +236,69 @@ class TestHelper(object):
return TestImportSession(self.lib, logfile=None, query=None,
paths=[import_dir])
# Library fixtures methods
def create_item(self, **values):
"""Return an `Item` instance with sensible default values.
The item receives its attributes from `**values` paratmeter. The
`title`, `artist`, `album`, `track`, `format` and `path`
attributes have defaults if they are not given as parameters.
The `title` attribute is formated with a running item count to
prevent duplicates. The default for the `path` attribute
respects the `format` value.
The item is attached to the database from `self.lib`.
"""
item_count = self._get_item_count()
values_ = {
'title': u't\u00eftle {0}',
'artist': u'the \u00e4rtist',
'album': u'the \u00e4lbum',
'track': item_count,
'format': 'MP3',
}
values_.update(values)
values_['title'] = values_['title'].format(item_count)
values_['db'] = self.lib
item = Item(**values_)
if 'path' not in values:
item['path'] = 'audio.' + item['format'].lower()
return item
def add_item(self, **values):
"""Add an item to the library and return it.
Creates the item by passing the parameters to `create_item()`.
If `path` is not set in `values` it is set to `item.destination()`.
"""
item = self.create_item(**values)
item.add(self.lib)
if 'path' not in values:
item['path'] = item.destination()
item.store()
return item
def add_item_fixture(self, **values):
"""Add an item with an actual audio file to the library.
"""
item = self.create_item(**values)
extension = item['format'].lower()
item['path'] = os.path.join(_common.RSRC, 'min.' + extension)
item.add(self.lib)
item.move(copy=True)
item.store()
return item
def add_album(self, **values):
item = self.add_item(**values)
return self.lib.add_album([item])
def add_item_fixtures(self, ext='mp3', count=1):
"""Add a number of items with files to the database.
"""
# TODO base this on `add_item()`
items = []
path = os.path.join(_common.RSRC, 'full.' + ext)
for i in range(count):
@ -283,6 +349,14 @@ class TestHelper(object):
for path in self._mediafile_fixtures:
os.remove(path)
def _get_item_count(self):
if not hasattr(self, '__item_count'):
count = 0
self.__item_count = count + 1
return count
# Running beets commands
def run_command(self, *args):
if hasattr(self, 'lib'):
lib = self.lib
@ -295,6 +369,8 @@ class TestHelper(object):
self.run_command(*args)
return out.getvalue()
# Safe file operations
def create_temp_dir(self):
"""Create a temporary directory and assign it into
`self.temp_dir`. Call `remove_temp_dir` later to delete it.

View file

@ -12,14 +12,52 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
import re
import os.path
import _common
from _common import unittest
from helper import TestHelper, control_stdin
import helper
from helper import control_stdin
from beets.mediafile import MediaFile
class TestHelper(helper.TestHelper):
def tagged_copy_cmd(self, tag):
"""Return a conversion command that copies files and appends
`tag` to the copy.
"""
if re.search('[^a-zA-Z0-9]', tag):
raise ValueError(u"tag '{0}' must only contain letters and digits"
.format(tag))
# FIXME This is not portable. For windows we need to use our own
# python script that performs the same task.
return u'cp $source $dest; printf {0} >> $dest'.format(tag)
def assertFileTag(self, path, tag):
"""Assert that the path is a file and the files content ends with `tag`.
"""
self.assertTrue(os.path.isfile(path),
u'{0} is not a file'.format(path))
with open(path) as f:
f.seek(-len(tag), os.SEEK_END)
self.assertEqual(f.read(), tag,
u'{0} is not tagged with {1}'.format(path, tag))
def assertNoFileTag(self, path, tag):
"""Assert that the path is a file and the files content does not
end with `tag`.
"""
self.assertTrue(os.path.isfile(path),
u'{0} is not a file'.format(path))
with open(path) as f:
f.seek(-len(tag), os.SEEK_END)
self.assertNotEqual(f.read(), tag,
u'{0} is unexpectedly tagged with {1}'
.format(path, tag))
class ImportConvertTest(unittest.TestCase, TestHelper):
def setUp(self):
@ -29,9 +67,7 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
self.config['convert'] = {
'dest': os.path.join(self.temp_dir, 'convert'),
# Append string so we can determine if the file was
# converted
'command': u'cp $source $dest; printf convert >> $dest',
'command': self.tagged_copy_cmd('convert'),
# Enforce running convert
'max_bitrate': 1,
'auto': True,
@ -45,7 +81,7 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
def test_import_converted(self):
self.importer.run()
item = self.lib.items().get()
self.assertConverted(item.path)
self.assertFileTag(item.path, 'convert')
def test_import_original_on_convert_error(self):
# `false` exits with non-zero code
@ -56,12 +92,6 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
self.assertIsNotNone(item)
self.assertTrue(os.path.isfile(item.path))
def assertConverted(self, path):
with open(path) as f:
f.seek(-7, os.SEEK_END)
self.assertEqual(f.read(), 'convert',
'{0} was not converted'.format(path))
class ConvertCliTest(unittest.TestCase, TestHelper):
@ -77,9 +107,9 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
'paths': {'default': 'converted'},
'format': 'mp3',
'formats': {
'mp3': 'cp $source $dest',
'mp3': self.tagged_copy_cmd('mp3'),
'opus': {
'command': 'cp $source $dest',
'command': self.tagged_copy_cmd('opus'),
'extension': 'ops',
}
}
@ -93,7 +123,18 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
with control_stdin('y'):
self.run_command('convert', self.item.path)
converted = os.path.join(self.convert_dest, 'converted.mp3')
self.assertTrue(os.path.isfile(converted))
self.assertFileTag(converted, 'mp3')
def test_convert_with_auto_confirmation(self):
self.run_command('convert', '--yes', self.item.path)
converted = os.path.join(self.convert_dest, 'converted.mp3')
self.assertFileTag(converted, 'mp3')
def test_rejecet_confirmation(self):
with control_stdin('n'):
self.run_command('convert', self.item.path)
converted = os.path.join(self.convert_dest, 'converted.mp3')
self.assertFalse(os.path.isfile(converted))
def test_convert_keep_new(self):
self.assertEqual(os.path.splitext(self.item.path)[1], '.ogg')
@ -108,7 +149,7 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
with control_stdin('y'):
self.run_command('convert', '--format', 'opus', self.item.path)
converted = os.path.join(self.convert_dest, 'converted.ops')
self.assertTrue(os.path.isfile(converted))
self.assertFileTag(converted, 'opus')
def test_embed_album_art(self):
self.config['convert']['embed'] = True
@ -125,6 +166,52 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
self.assertEqual(mediafile.images[0].data, image_data)
class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper):
"""Test the effect of the `never_convert_lossy_files` option.
"""
def setUp(self):
self.setup_beets(disk=True) # Converter is threaded
self.load_plugins('convert')
self.convert_dest = os.path.join(self.temp_dir, 'convert_dest')
self.config['convert'] = {
'dest': self.convert_dest,
'paths': {'default': 'converted'},
'never_convert_lossy_files': True,
'format': 'mp3',
'formats': {
'mp3': self.tagged_copy_cmd('mp3'),
}
}
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_transcode_from_lossles(self):
[item] = self.add_item_fixtures(ext='flac')
with control_stdin('y'):
self.run_command('convert', item.path)
converted = os.path.join(self.convert_dest, 'converted.mp3')
self.assertFileTag(converted, 'mp3')
def test_transcode_from_lossy(self):
self.config['convert']['never_convert_lossy_files'] = False
[item] = self.add_item_fixtures(ext='ogg')
with control_stdin('y'):
self.run_command('convert', item.path)
converted = os.path.join(self.convert_dest, 'converted.mp3')
self.assertFileTag(converted, 'mp3')
def test_transcode_from_lossy_prevented(self):
[item] = self.add_item_fixtures(ext='ogg')
with control_stdin('y'):
self.run_command('convert', item.path)
converted = os.path.join(self.convert_dest, 'converted.ogg')
self.assertNoFileTag(converted, 'mp3')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -249,6 +249,20 @@ class ModelTest(unittest.TestCase):
model.some_float_field = None
self.assertEqual(model.some_float_field, 0.0)
def test_load_deleted_flex_field(self):
model1 = TestModel1()
model1['flex_field'] = True
model1.add(self.db)
model2 = self.db._get(TestModel1, model1.id)
self.assertIn('flex_field', model2)
del model1['flex_field']
model1.store()
model2.load()
self.assertNotIn('flex_field', model2)
class FormatTest(unittest.TestCase):
def test_format_fixed_field(self):
@ -420,7 +434,7 @@ class SortFromStringsTest(unittest.TestCase):
def test_zero_parts(self):
s = self.sfs([])
self.assertIsNone(s)
self.assertIsInstance(s, dbcore.query.NullSort)
def test_one_parts(self):
s = self.sfs(['field+'])
@ -439,7 +453,7 @@ class SortFromStringsTest(unittest.TestCase):
def test_flex_field_sort(self):
s = self.sfs(['flex_field+'])
self.assertIsInstance(s, dbcore.query.MultipleSort)
self.assertIsInstance(s.sorts[0], dbcore.query.FlexFieldSort)
self.assertIsInstance(s.sorts[0], dbcore.query.SlowFieldSort)
def suite():

View file

@ -70,9 +70,17 @@ class EchonestCliTest(unittest.TestCase, TestHelper):
self.run_command('echonest')
item.load()
self.assertEqual(item['danceability'], '0.5')
self.assertEqual(item['danceability'], 0.5)
self.assertEqual(item['liveness'], 0.5)
self.assertEqual(item['bpm'], 120)
self.assertEqual(item['initial_key'], 'C#m')
def test_custom_field_range_query(self):
item = Item(liveness=2.2)
item.add(self.lib)
item = self.lib.items('liveness:2.2..3').get()
self.assertEqual(item['liveness'], 2.2)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

49
test/test_fetchart.py Normal file
View file

@ -0,0 +1,49 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# 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.
import os.path
from _common import unittest
from helper import TestHelper
class FetchartCliTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
self.load_plugins('fetchart')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_set_art_from_folder(self):
self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg'
self.config['art_filename'] = 'mycover'
album = self.add_album()
self.touch('c\xc3\xb6ver.jpg', dir=album.path, content='IMAGE')
self.run_command('fetchart')
cover_path = os.path.join(album.path, 'mycover.jpg')
album.load()
self.assertEqual(album['artpath'], cover_path)
with open(cover_path, 'r') as f:
self.assertEqual(f.read(), 'IMAGE')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

75
test/test_plugins.py Normal file
View file

@ -0,0 +1,75 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# 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 mock import patch
from _common import unittest
from helper import TestHelper
from beets import plugins
from beets.library import Item
from beets.dbcore import types
class PluginTest(unittest.TestCase, TestHelper):
def setUp(self):
# FIXME the mocking code is horrific, but this is the lowest and
# earliest level of the plugin mechanism we can hook into.
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
self._plugin_classes = set()
load_plugins = self._plugin_loader_patch.start()
def myload(names=()):
plugins._classes.update(self._plugin_classes)
load_plugins.side_effect = myload
self.setup_beets()
def tearDown(self):
self._plugin_loader_patch.stop()
self.unload_plugins()
self.teardown_beets()
def test_flex_field_type(self):
class RatingPlugin(plugins.BeetsPlugin):
item_types = {'rating': types.Float()}
self.register_plugin(RatingPlugin)
self.config['plugins'] = 'rating'
item = Item(path='apath', artist='aaa')
item.add(self.lib)
# Do not match unset values
out = self.run_with_output('ls', 'rating:1..3')
self.assertNotIn('aaa', out)
self.run_command('modify', 'rating=2', '--yes')
# Match in range
out = self.run_with_output('ls', 'rating:1..3')
self.assertIn('aaa', out)
# Don't match out of range
out = self.run_with_output('ls', 'rating:3..5')
self.assertNotIn('aaa', out)
def register_plugin(self, plugin_class):
self._plugin_classes.add(plugin_class)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

View file

@ -16,8 +16,12 @@
"""
import _common
from _common import unittest
from helper import TestHelper
import beets.library
from beets import dbcore
from beets.dbcore import types
from beets.library import Library, Item
class AnyFieldQueryTest(_common.LibTestCase):
@ -374,6 +378,42 @@ class PathQueryTest(_common.LibTestCase, AssertsMixin):
self.assert_matched(results, ['path item'])
class IntQueryTest(unittest.TestCase, TestHelper):
def setUp(self):
self.lib = Library(':memory:')
def test_exact_value_match(self):
item = self.add_item(bpm=120)
matched = self.lib.items('bpm:120').get()
self.assertEqual(item.id, matched.id)
def test_range_match(self):
item = self.add_item(bpm=120)
self.add_item(bpm=130)
matched = self.lib.items('bpm:110..125')
self.assertEqual(1, len(matched))
self.assertEqual(item.id, matched.get().id)
def test_flex_range_match(self):
Item._types = {'myint': types.Integer()}
item = self.add_item(myint=2)
matched = self.lib.items('myint:2').get()
self.assertEqual(item.id, matched.id)
def test_flex_dont_match_missing(self):
Item._types = {'myint': types.Integer()}
self.add_item()
matched = self.lib.items('myint:2').get()
self.assertIsNone(matched)
def test_no_substring_match(self):
self.add_item(bpm=120)
matched = self.lib.items('bpm:12').get()
self.assertIsNone(matched)
class DefaultSearchFieldsTest(DummyDataTestCase):
def test_albums_matches_album(self):
albums = list(self.lib.albums('baz'))

View file

@ -113,8 +113,8 @@ class SortFixedFieldTest(DummyDataTestCase):
s1 = dbcore.query.FixedFieldSort("album", True)
s2 = dbcore.query.FixedFieldSort("year", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.items(q, sort)
self.assertLessEqual(results[0]['album'], results[1]['album'])
self.assertLessEqual(results[1]['album'], results[2]['album'])
@ -131,7 +131,7 @@ class SortFixedFieldTest(DummyDataTestCase):
class SortFlexFieldTest(DummyDataTestCase):
def test_sort_asc(self):
q = ''
sort = dbcore.query.FlexFieldSort(beets.library.Item, "flex1", True)
sort = dbcore.query.SlowFieldSort("flex1", True)
results = self.lib.items(q, sort)
self.assertLessEqual(results[0]['flex1'], results[1]['flex1'])
self.assertEqual(results[0]['flex1'], 'flex1-0')
@ -143,7 +143,7 @@ class SortFlexFieldTest(DummyDataTestCase):
def test_sort_desc(self):
q = ''
sort = dbcore.query.FlexFieldSort(beets.library.Item, "flex1", False)
sort = dbcore.query.SlowFieldSort("flex1", False)
results = self.lib.items(q, sort)
self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1'])
self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1'])
@ -157,11 +157,11 @@ class SortFlexFieldTest(DummyDataTestCase):
def test_sort_two_field(self):
q = ''
s1 = dbcore.query.FlexFieldSort(beets.library.Item, "flex2", False)
s2 = dbcore.query.FlexFieldSort(beets.library.Item, "flex1", True)
s1 = dbcore.query.SlowFieldSort("flex2", False)
s2 = dbcore.query.SlowFieldSort("flex1", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.items(q, sort)
self.assertGreaterEqual(results[0]['flex2'], results[1]['flex2'])
self.assertGreaterEqual(results[1]['flex2'], results[2]['flex2'])
@ -205,8 +205,8 @@ class SortAlbumFixedFieldTest(DummyDataTestCase):
s1 = dbcore.query.FixedFieldSort("genre", True)
s2 = dbcore.query.FixedFieldSort("album", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['genre'], results[1]['genre'])
self.assertLessEqual(results[1]['genre'], results[2]['genre'])
@ -220,10 +220,10 @@ class SortAlbumFixedFieldTest(DummyDataTestCase):
self.assertEqual(r1.id, r2.id)
class SortAlbumFlexdFieldTest(DummyDataTestCase):
class SortAlbumFlexFieldTest(DummyDataTestCase):
def test_sort_asc(self):
q = ''
sort = dbcore.query.FlexFieldSort(beets.library.Album, "flex1", True)
sort = dbcore.query.SlowFieldSort("flex1", True)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['flex1'], results[1]['flex1'])
self.assertLessEqual(results[1]['flex1'], results[2]['flex1'])
@ -235,7 +235,7 @@ class SortAlbumFlexdFieldTest(DummyDataTestCase):
def test_sort_desc(self):
q = ''
sort = dbcore.query.FlexFieldSort(beets.library.Album, "flex1", False)
sort = dbcore.query.SlowFieldSort("flex1", False)
results = self.lib.albums(q, sort)
self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1'])
self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1'])
@ -247,11 +247,11 @@ class SortAlbumFlexdFieldTest(DummyDataTestCase):
def test_sort_two_field_asc(self):
q = ''
s1 = dbcore.query.FlexFieldSort(beets.library.Album, "flex2", True)
s2 = dbcore.query.FlexFieldSort(beets.library.Album, "flex1", True)
s1 = dbcore.query.SlowFieldSort("flex2", True)
s2 = dbcore.query.SlowFieldSort("flex1", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['flex2'], results[1]['flex2'])
self.assertLessEqual(results[1]['flex2'], results[2]['flex2'])
@ -268,8 +268,7 @@ class SortAlbumFlexdFieldTest(DummyDataTestCase):
class SortAlbumComputedFieldTest(DummyDataTestCase):
def test_sort_asc(self):
q = ''
sort = dbcore.query.ComputedFieldSort(beets.library.Album, "path",
True)
sort = dbcore.query.SlowFieldSort("path", True)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['path'], results[1]['path'])
self.assertLessEqual(results[1]['path'], results[2]['path'])
@ -281,8 +280,7 @@ class SortAlbumComputedFieldTest(DummyDataTestCase):
def test_sort_desc(self):
q = ''
sort = dbcore.query.ComputedFieldSort(beets.library.Album, "path",
False)
sort = dbcore.query.SlowFieldSort("path", False)
results = self.lib.albums(q, sort)
self.assertGreaterEqual(results[0]['path'], results[1]['path'])
self.assertGreaterEqual(results[1]['path'], results[2]['path'])
@ -296,11 +294,11 @@ class SortAlbumComputedFieldTest(DummyDataTestCase):
class SortCombinedFieldTest(DummyDataTestCase):
def test_computed_first(self):
q = ''
s1 = dbcore.query.ComputedFieldSort(beets.library.Album, "path", True)
s1 = dbcore.query.SlowFieldSort("path", True)
s2 = dbcore.query.FixedFieldSort("year", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['path'], results[1]['path'])
self.assertLessEqual(results[1]['path'], results[2]['path'])
@ -312,10 +310,10 @@ class SortCombinedFieldTest(DummyDataTestCase):
def test_computed_second(self):
q = ''
s1 = dbcore.query.FixedFieldSort("year", True)
s2 = dbcore.query.ComputedFieldSort(beets.library.Album, "path", True)
s2 = dbcore.query.SlowFieldSort("path", True)
sort = dbcore.query.MultipleSort()
sort.add_criteria(s1)
sort.add_criteria(s2)
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.albums(q, sort)
self.assertLessEqual(results[0]['year'], results[1]['year'])
self.assertLessEqual(results[1]['year'], results[2]['year'])

133
test/test_types_plugin.py Normal file
View file

@ -0,0 +1,133 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# 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.
import time
from datetime import datetime
from _common import unittest
from helper import TestHelper
from beets.util.confit import ConfigValueError
class TypesPluginTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
self.load_plugins('types')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_integer_modify_and_query(self):
self.config['types'] = {'myint': 'int'}
item = self.add_item(artist='aaa')
# Do not match unset values
out = self.list('myint:1..3')
self.assertEqual('', out)
self.modify('myint=2')
item.load()
self.assertEqual(item['myint'], 2)
# Match in range
out = self.list('myint:1..3')
self.assertIn('aaa', out)
def test_float_modify_and_query(self):
self.config['types'] = {'myfloat': 'float'}
item = self.add_item(artist='aaa')
self.modify('myfloat=-9.1')
item.load()
self.assertEqual(item['myfloat'], -9.1)
# Match in range
out = self.list('myfloat:-10..0')
self.assertIn('aaa', out)
def test_bool_modify_and_query(self):
self.config['types'] = {'mybool': 'bool'}
true = self.add_item(artist='true')
false = self.add_item(artist='false')
self.add_item(artist='unset')
# Set true
self.modify('mybool=1', 'artist:true')
true.load()
self.assertEqual(true['mybool'], True)
# Set false
self.modify('mybool=false', 'artist:false')
false.load()
self.assertEqual(false['mybool'], False)
# Query bools
out = self.list('mybool:true', '$artist $mybool')
self.assertEqual('true True', out)
out = self.list('mybool:false', '$artist $mybool')
# Dealing with unset fields?
# self.assertEqual('false False', out)
# out = self.list('mybool:', '$artist $mybool')
# self.assertIn('unset $mybool', out)
def test_date_modify_and_query(self):
self.config['types'] = {'mydate': 'date'}
# FIXME parsing should also work with default time format
self.config['time_format'] = '%Y-%m-%d'
old = self.add_item(artist='prince')
new = self.add_item(artist='britney')
self.modify('mydate=1999-01-01', 'artist:prince')
old.load()
self.assertEqual(old['mydate'], mktime(1999, 01, 01))
self.modify('mydate=1999-12-30', 'artist:britney')
new.load()
self.assertEqual(new['mydate'], mktime(1999, 12, 30))
# Match in range
out = self.list('mydate:..1999-07', '$artist $mydate')
self.assertEqual('prince 1999-01-01', out)
# FIXME some sort of timezone issue here
# out = self.list('mydate:1999-12-30', '$artist $mydate')
# self.assertEqual('britney 1999-12-30', out)
def test_unknown_type_error(self):
self.config['types'] = {'flex': 'unkown type'}
with self.assertRaises(ConfigValueError):
self.run_command('ls')
def modify(self, *args):
return self.run_with_output('modify', '--yes', '--nowrite',
'--nomove', *args)
def list(self, query, fmt='$artist - $album - $title'):
return self.run_with_output('ls', '-f', fmt, query).strip()
def mktime(*args):
return time.mktime(datetime(*args).timetuple())
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')