diff --git a/beets/autotag/match.py b/beets/autotag/match.py index fb031802c..aa0c21dba 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -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.') diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 050ddbd81..0ec24dfd6 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -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 diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 6a10f2533..1f1a9a26a 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -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 diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index b51194b33..8a50e04d5 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -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 diff --git a/beets/library.py b/beets/library.py index 1c7f56421..dd0da3b30 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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): diff --git a/beets/mediafile.py b/beets/mediafile.py index 4e83d0543..6c08dac3f 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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) diff --git a/beets/plugins.py b/beets/plugins.py index 3dca22a97..2ee5f88f2 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -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. diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 2e61611d9..c3bb7a158 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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) diff --git a/beets/util/confit.py b/beets/util/confit.py index 4ffe68cea..c9051de53 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -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): diff --git a/beetsplug/convert.py b/beetsplug/convert.py index b840078af..a86fc017a 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -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] diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index e183eb8ef..e31a8c425 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -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({ diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 632982edc..1474a7b09 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -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: diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index eba6c6cab..3a9654c7d 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -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 """ diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt index e45cad09a..ad344afec 100644 --- a/beetsplug/lastgenre/genres.txt +++ b/beetsplug/lastgenre/genres.txt @@ -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 diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 56522a8de..f03e284e3 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -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({ diff --git a/beetsplug/play.py b/beetsplug/play.py index 9a1bc444c..fb4167124 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -113,7 +113,8 @@ class PlayPlugin(BeetsPlugin): config['play'].add({ 'command': None, - 'use_folders': False + 'use_folders': False, + 'relative_to': None, }) def commands(self): diff --git a/beetsplug/types.py b/beetsplug/types.py new file mode 100644 index 000000000..68aea35c7 --- /dev/null +++ b/beetsplug/types.py @@ -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 diff --git a/docs/changelog.rst b/docs/changelog.rst index c67a38472..db1b82b7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index ffb2d0721..c585c9001 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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 `) + from the command line. + +* User input for flexible fields may be validated. diff --git a/docs/faq.rst b/docs/faq.rst index 27bc29d9a..ed88963a9 100644 --- a/docs/faq.rst +++ b/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 ` pages. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index b94f874d7..135d239a7 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -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 `. +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 diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index cdf802ba0..b66d801ea 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 58f3432be..c2cd59160 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -21,7 +21,7 @@ your ``plugins`` line in :doc:`config file `. 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 diff --git a/docs/plugins/types.rst b/docs/plugins/types.rst new file mode 100644 index 000000000..41419d758 --- /dev/null +++ b/docs/plugins/types.rst @@ -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 `. The +configuration section should map field name to one of ``int``, ``float``, +``bool``, or ``date``. + +Here's an example: + + types: + rating: int diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 468ef0664..d8dff4c33 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -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 diff --git a/test/helper.py b/test/helper.py index 3bc95b319..6beac5d5a 100644 --- a/test/helper.py +++ b/test/helper.py @@ -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. diff --git a/test/test_convert.py b/test/test_convert.py index 24ed44748..be6d12202 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -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__) diff --git a/test/test_dbcore.py b/test/test_dbcore.py index bca88cbda..68a4b61ef 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -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(): diff --git a/test/test_echonest.py b/test/test_echonest.py index da05adde3..43f006dfc 100644 --- a/test/test_echonest.py +++ b/test/test_echonest.py @@ -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__) diff --git a/test/test_fetchart.py b/test/test_fetchart.py new file mode 100644 index 000000000..5e36f9145 --- /dev/null +++ b/test/test_fetchart.py @@ -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') diff --git a/test/test_plugins.py b/test/test_plugins.py new file mode 100644 index 000000000..a48dd10ae --- /dev/null +++ b/test/test_plugins.py @@ -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') diff --git a/test/test_query.py b/test/test_query.py index 4d85696d4..0cf53c92b 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -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')) diff --git a/test/test_sort.py b/test/test_sort.py index 76e5a35cb..316e9800d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -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']) diff --git a/test/test_types_plugin.py b/test/test_types_plugin.py new file mode 100644 index 000000000..5e34db9e2 --- /dev/null +++ b/test/test_types_plugin.py @@ -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')