mirror of
https://github.com/beetbox/beets.git
synced 2025-12-30 04:22:40 +01:00
Merge branch 'master' of https://github.com/sampsyo/beets
This commit is contained in:
commit
725cb9b60a
34 changed files with 910 additions and 334 deletions
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
42
beetsplug/types.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
docs/faq.rst
11
docs/faq.rst
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
17
docs/plugins/types.rst
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
49
test/test_fetchart.py
Normal 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
75
test/test_plugins.py
Normal 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')
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
133
test/test_types_plugin.py
Normal 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')
|
||||
Loading…
Reference in a new issue