diff --git a/beets/__init__.py b/beets/__init__.py index d4f4c5db8..a2a0bfbd5 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.3.7' +__version__ = '1.3.8' __author__ = 'Adrian Sampson ' import beets.library diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 689ab44b1..82dea99d5 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -55,6 +55,9 @@ list_format_item: $artist - $album - $title list_format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' +sort_album: smartartist+ +sort_item: smartartist+ + paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index fed65f482..fdf6b4695 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -19,5 +19,6 @@ from .db import Model, Database from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery from .types import Type from .queryparse import query_from_strings +from .queryparse import sort_from_strings # flake8: noqa diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 68f144b7a..5172ad523 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 +from .query import MatchQuery, build_sql from .types import BASE_TYPE @@ -483,40 +483,64 @@ class Results(object): """An item query result set. Iterating over the collection lazily constructs LibModel objects that reflect database rows. """ - def __init__(self, model_class, rows, db, query=None): + def __init__(self, model_class, rows, db, query=None, sort=None): """Create a result set that will construct objects of type `model_class`, which should be a subclass of `LibModel`, out of the query result mapping in `rows`. The new objects are - associated with the database `db`. If `query` is provided, it is - used as a predicate to filter the results for a "slow query" that - cannot be evaluated by the database directly. + associated with the database `db`. + If `query` is provided, it is used as a predicate to filter the results + for a "slow query" that cannot be evaluated by the database directly. + If `sort` is provided, it is used to sort the full list of results + before returning. This means it is a "slow sort" and all objects must + be built before returning the first one. """ self.model_class = model_class self.rows = rows self.db = db self.query = query + self.sort = sort def __iter__(self): """Construct Python objects for all rows that pass the query predicate. """ - for row in self.rows: - # Get the flexible attributes for the object. - with self.db.transaction() as tx: - flex_rows = tx.query( - 'SELECT * FROM {0} WHERE entity_id=?'.format( - self.model_class._flex_table - ), - (row['id'],) - ) - values = dict(row) - flex_values = dict((row['key'], row['value']) for row in flex_rows) + if self.sort: + # Slow sort. Must build the full list first. + objects = [] + for row in self.rows: + obj = self._make_model(row) + # check the predicate if any + if not self.query or self.query.match(obj): + objects.append(obj) + # Now that we have the full list, we can sort it + objects = self.sort.sort(objects) + for o in objects: + yield o + else: + for row in self.rows: + obj = self._make_model(row) + # check the predicate if any + if not self.query or self.query.match(obj): + yield obj - # Construct the Python object and yield it if it passes the - # predicate. - obj = self.model_class._awaken(self.db, values, flex_values) - if not self.query or self.query.match(obj): - yield obj + def _make_model(self, row): + # Get the flexible attributes for the object. + with self.db.transaction() as tx: + flex_rows = tx.query( + 'SELECT * FROM {0} WHERE entity_id=?'.format( + self.model_class._flex_table + ), + (row['id'],) + ) + + cols = dict(row) + values = dict((k, v) for (k, v) in cols.items() + if not k[:4] == 'flex') + flex_values = dict((row['key'], row['value']) for row in flex_rows) + + # Construct the Python object + obj = self.model_class._awaken(self.db, values, flex_values) + return obj def __len__(self): """Get the number of matching objects. @@ -739,24 +763,20 @@ class Database(object): # Querying. - def _fetch(self, model_cls, query, order_by=None): + def _fetch(self, model_cls, query, sort_order=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, - `order_by` is a SQLite ORDER BY clause for sorting. - """ - where, subvals = query.clause() + `sort_order` is either a SQLite ORDER BY clause for sorting or a + Sort object. + """ + + sql, subvals, query, sort = build_sql(model_cls, query, sort_order) - sql = "SELECT * FROM {0} WHERE {1}".format( - model_cls._table, - where or '1', - ) - if order_by: - sql += " ORDER BY {0}".format(order_by) with self.transaction() as tx: rows = tx.query(sql, subvals) - return Results(model_cls, rows, self, None if where else query) + return Results(model_cls, rows, self, query, sort) 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 805f2cac9..6a10f2533 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -15,6 +15,7 @@ """The Query type hierarchy for DBCore. """ import re +from operator import attrgetter from beets import util from datetime import datetime, timedelta @@ -497,3 +498,257 @@ class DateQuery(FieldQuery): # Match any date. clause = '1' 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 "" + + def union_clause(self): + """ Generates a union sql fragment if the sort operation requires one, + an empty string otherwise. + """ + return "" + + def order_clause(self): + """Generates a sql fragment to be use in a ORDER BY clause or None if + it's a slow query. + """ + 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. + """ + return sorted(items, key=lambda x: x) + + def is_slow(self): + 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. + """ + + def __init__(self): + self.sorts = [] + + def add_criteria(self, sort): + self.sorts.append(sort) + + def _sql_sorts(self): + """ Returns the list of sort for which sql can be used + """ + # 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: + sql_sorts.append(sort) + else: + break + 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: + order = sort.order_clause() + order_strings.append(order) + + return ",".join(order_strings) + + def is_slow(self): + for sort in self.sorts: + if sort.is_slow(): + return True + return False + + def sort(self, items): + slow_sorts = [] + switch_slow = False + for sort in reversed(self.sorts): + if switch_slow: + slow_sorts.append(sort) + elif sort.order_clause() is None: + switch_slow = True + slow_sorts.append(sort) + else: + pass + + for sort in slow_sorts: + items = sort.sort(items) + return items + + +class FlexFieldSort(Sort): + """Sort object to sort on a flexible attribute field + """ + def __init__(self, model_cls, field, is_ascending): + self.model_cls = model_cls + self.field = field + self.is_ascending = is_ascending + + def select_clause(self): + """ Return a select sql fragment. + """ + return "sort_flexattr{0!s}.value as flex_{0!s} ".format(self.field) + + 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)) + + +class FixedFieldSort(Sort): + """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" + 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. + """ + 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' + else: + return "" + + order_str = ('(CASE {0} WHEN NULL THEN {1} ' + 'WHEN "" THEN {1} ' + 'ELSE {0} END) {2} ').format(exp1, exp2, order) + return order_str + + +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() + + 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} + + +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 diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index a767b56d1..b51194b33 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -121,3 +121,33 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts): if not subqueries: # No terms in query. subqueries = [query.TrueQuery()] return query_cls(subqueries) + + +def construct_sort_part(model_cls, part): + """ Creates a Sort object from a single criteria. Returns a `Sort` instance. + """ + sort = None + field = part[:-1] + is_ascending = (part[-1] == '+') + 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) + else: + # Neither fixed nor computed : must be a flex attr. + sort = query.FlexFieldSort(model_cls, field, is_ascending) + return sort + + +def sort_from_strings(model_cls, sort_parts): + """Creates a Sort object 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 diff --git a/beets/library.py b/beets/library.py index 4dbaa767b..790b97f21 100644 --- a/beets/library.py +++ b/beets/library.py @@ -548,7 +548,7 @@ class Item(LibModel): for query, path_format in path_formats: if query == PF_KEY_DEFAULT: continue - query = get_query(query, type(self)) + (query, _) = get_query_sort(query, type(self)) if query.match(self): # The query matches the item! Use the corresponding path # format. @@ -896,9 +896,10 @@ class Album(LibModel): # Query construction helper. -def get_query(val, model_cls): +def get_query_sort(val, model_cls): """Take a value which may be None, a query string, a query string - list, or a Query object, and return a suitable Query object. + list, or a Query object, and return a suitable Query object and Sort + object. `model_cls` is the subclass of Model indicating which entity this is a query for (i.e., Album or Item) and is used to determine which @@ -919,7 +920,7 @@ def get_query(val, model_cls): val = [s.decode('utf8') for s in shlex.split(val)] if val is None: - return dbcore.query.TrueQuery() + return (dbcore.query.TrueQuery(), None) elif isinstance(val, list) or isinstance(val, tuple): # Special-case path-like queries, which are non-field queries @@ -937,18 +938,23 @@ def get_query(val, model_cls): path_parts = () non_path_parts = val + # separate query token and sort token + query_val = [s for s in non_path_parts if not s.endswith(('+', '-'))] + sort_val = [s for s in non_path_parts if s.endswith(('+', '-'))] + # Parse remaining parts and construct an AndQuery. query = dbcore.query_from_strings( - dbcore.AndQuery, model_cls, prefixes, non_path_parts + dbcore.AndQuery, model_cls, prefixes, query_val ) + sort = dbcore.sort_from_strings(model_cls, sort_val) # Add path queries to aggregate query. if path_parts: query.subqueries += [PathQuery('path', s) for s in path_parts] - return query + return query, sort elif isinstance(val, dbcore.Query): - return val + return val, None else: raise ValueError('query must be None or have type Query or str') @@ -1015,30 +1021,30 @@ class Library(dbcore.Database): # Querying. - def _fetch(self, model_cls, query, order_by=None): - """Parse a query and fetch. - """ + def _fetch(self, model_cls, query, sort_order=None): + """Parse a query and fetch. If a order specification is present in the + query string the sort_order argument is ignored. + """ + query, sort = get_query_sort(query, model_cls) + sort = sort or sort_order + return super(Library, self)._fetch( - model_cls, get_query(query, model_cls), order_by + model_cls, query, sort ) - def albums(self, query=None): + def albums(self, query=None, sort_order=None): """Get a sorted list of :class:`Album` objects matching the - given query. + given sort order. If a order specification is present in the query + string the sort_order argument is ignored. """ - order = '{0}, album'.format( - _orelse("albumartist_sort", "albumartist") - ) - return self._fetch(Album, query, order) + return self._fetch(Album, query, sort_order) - def items(self, query=None): + def items(self, query=None, sort_order=None): """Get a sorted list of :class:`Item` objects matching the given - query. + given sort order. If a order specification is present in the query + string the sort_order argument is ignored. """ - order = '{0}, album, disc, track'.format( - _orelse("artist_sort", "artist") - ) - return self._fetch(Item, query, order) + return self._fetch(Item, query, sort_order) # Convenience accessors. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index c7afac4d8..7400b7356 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -39,6 +39,7 @@ from beets.util.functemplate import Template from beets import library from beets import config from beets.util.confit import _package_path +from beets.dbcore import sort_from_strings VARIOUS_ARTISTS = u'Various Artists' @@ -966,11 +967,18 @@ def list_items(lib, query, album, fmt): albums instead of single items. """ tmpl = Template(ui._pick_format(album, fmt)) + if album: - for album in lib.albums(query): + sort_parts = str(config['sort_album']).split() + sort_order = sort_from_strings(library.Album, + sort_parts) + for album in lib.albums(query, sort_order): ui.print_obj(album, lib, tmpl) else: - for item in lib.items(query): + sort_parts = str(config['sort_item']).split() + sort_order = sort_from_strings(library.Item, + sort_parts) + for item in lib.items(query, sort_order): ui.print_obj(item, lib, tmpl) diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index 2c4785be8..177076bcd 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -17,7 +17,7 @@ import logging from beets.plugins import BeetsPlugin from beets.importer import action -from beets.library import get_query +from beets.library import get_query_sort from beets.library import Item from beets.library import Album @@ -57,9 +57,9 @@ class IHatePlugin(BeetsPlugin): for query_string in action_patterns: query = None if task.is_album: - query = get_query(query_string, Album) + (query, _) = get_query_sort(query_string, Album) else: - query = get_query(query_string, Item) + (query, _) = get_query_sort(query_string, Item) if any(query.match(item) for item in task.imported_items()): return True return False diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 118dc361b..6beb0ad59 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -42,7 +42,7 @@ def _items_for_query(lib, playlist, album=False): query_strings = [query_strings] model = library.Album if album else library.Item query = dbcore.OrQuery( - [library.get_query(q, model) for q in query_strings] + [library.get_query_sort(q, model)[0] for q in query_strings] ) # Execute query, depending on type. diff --git a/docs/changelog.rst b/docs/changelog.rst index 114fbba67..53ed48e0d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,35 +1,52 @@ Changelog ========= -1.3.7 (in development) +1.3.8 (in development) ---------------------- -New stuff +This release adds **sorting** to beets queries. See :ref:`query-sort`. + +1.3.7 (August 22, 2014) +----------------------- + +This release of beets fixes all the bugs, and you can be confident that you +will never again find any bugs in beets, ever. +It also adds support for plain old AIFF files and adds three more plugins, +including a nifty one that lets you measure a song's tempo by tapping out the +beat on your keyboard. +The importer deals more elegantly with duplicates and you can broaden your +cover art search to the entire web with Google Image Search. + +The big new features are: + +* Support for AIFF files. Tags are stored as ID3 frames in one of the file's + IFF chunks. Thanks to Evan Purkhiser for contributing support to `Mutagen`_. * The new :doc:`/plugins/importadded` reads files' modification times to set their "added" date. Thanks to Stig Inge Lea Bjørnsen. -* Support for AIFF files. Tags are stored as ID3 frames in one of the file's - IFF chunks. -* A new :ref:`required` configuration option for the importer skips matches - that are missing certain data. Thanks to oprietop. * The new :doc:`/plugins/bpm` lets you manually measure the tempo of a playing song. Thanks to aroquen. * The new :doc:`/plugins/spotify` generates playlists for your `Spotify`_ account. Thanks to Olin Gay. +* A new :ref:`required` configuration option for the importer skips matches + that are missing certain data. Thanks to oprietop. * When the importer detects duplicates, it now shows you some details about the potentially-replaced music so you can make an informed decision. Thanks to Howard Jones. +* :doc:`/plugins/fetchart`: You can now optionally search for cover art on + Google Image Search. Thanks to Lemutar. +* A new :ref:`asciify-paths` configuration option replaces all non-ASCII + characters in paths. +.. _Mutagen: https://bitbucket.org/lazka/mutagen .. _Spotify: https://www.spotify.com/ -Little improvements and fixes: +And the multitude of little improvements and fixes: +* Compatibility with the latest version of `Mutagen`_, 1.23. * :doc:`/plugins/web`: Lyrics now display readably with correct line breaks. Also, the detail view scrolls to reveal all of the lyrics. Thanks to Meet Udeshi. -* Compatibility with the latest version of Mutagen, 1.23. -* :doc:`/plugins/fetchart`: You can now optionally search for cover art on - Google Image Search. Thanks to Lemutar. * :doc:`/plugins/play`: The ``command`` config option can now contain arguments (rather than just an executable). Thanks to Alessandro Ghedini. * Fix an error when using the :ref:`modify-cmd` command to remove a flexible @@ -51,11 +68,11 @@ Little improvements and fixes: * Don't display changes for fields that are not in the restricted field set. This fixes :ref:`write-cmd` showing changes for fields that are not written to the file. -* :ref:`write-cmd` command: Don't display the item name if there are no - changes for it. -* When using both :doc:`/plugins/convert` and :doc:`/plugins/scrub`, avoid - scrubbing the source file of conversions. (Fix a regression introduced in - the previous release.) +* The :ref:`write-cmd` command avoids displaying the item name if there are + no changes for it. +* When using both the :doc:`/plugins/convert` and the :doc:`/plugins/scrub`, + avoid scrubbing the source file of conversions. (Fix a regression introduced + in the previous release.) * :doc:`/plugins/replaygain`: Logging is now quieter during import. Thanks to Yevgeny Bezman. * :doc:`/plugins/fetchart`: When loading art from the filesystem, we now @@ -71,7 +88,7 @@ Little improvements and fixes: * :doc:`/plugins/bucket`: You can now customize the definition of alphanumeric "ranges" using regular expressions. And the heuristic for detecting years has been improved. Thanks to sotho. -* Already imported singleton tracks are skipped when resuming an +* Already-imported singleton tracks are skipped when resuming an import. * :doc:`/plugins/chroma`: A new ``auto`` configuration option disables fingerprinting on import. Thanks to ddettrittus. @@ -79,8 +96,6 @@ Little improvements and fixes: transcoding preset from the command-line. * :doc:`/plugins/convert`: Transcoding presets can now omit their filename extensions (extensions default to the name of the preset). -* A new :ref:`asciify-paths` configuration option replaces all non-ASCII - characters in paths. * :doc:`/plugins/convert`: A new ``--pretend`` option lets you preview the commands the plugin will execute without actually taking any action. Thanks to Dietrich Daroch. diff --git a/docs/conf.py b/docs/conf.py index 0040583c1..291685116 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ project = u'beets' copyright = u'2012, Adrian Sampson' version = '1.3' -release = '1.3.7' +release = '1.3.8' pygments_style = 'sphinx' diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 1f6d9e2f7..468ef0664 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -174,8 +174,9 @@ 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``. (Read -more in :doc:`query`.) +"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`.) 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/docs/reference/config.rst b/docs/reference/config.rst index 04deddd37..0d3d4b644 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -188,6 +188,24 @@ Format to use when listing *albums* with :ref:`list-cmd` and other commands. Defaults to ``$albumartist - $album``. The ``-f`` command-line option overrides this setting. +.. _sort_item: + +sort_item +~~~~~~~~~ + +Sort order to use when listing *individual items* with the :ref:`list-cmd` +command and other commands that need to print out items. Defaults to +``smartartist+``. Any command-line sort order overrides this setting. + +.. _sort_album: + +sort_album +~~~~~~~~~~ + +Sort order to use when listing *albums* with the :ref:`list-cmd` +command. Defaults to ``smartartist+``. Any command-line sort order overrides +this setting. + .. _original_date: original_date diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 464b842dc..b85a03962 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -183,3 +183,32 @@ equivalent:: Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. + + +.. _query-sort: + +Sort Order +---------- + +You can also specify the order used when outputting the results. Of course, this +is only useful when displaying the result, for example with the ``list`` +command, and is useless when the query is used as a filter for an command. Use +the name of the `field` you want to sort on, followed by a ``+`` or ``-`` sign +if you want ascending or descending sort. For example this command:: + + $ beet list -a year+ + +will list all albums in chronological order. + +There is a special ``smartartist`` sort that uses sort-specific field ( +``artist_sort`` for items and ``albumartist_sort`` for albums) but falls back to +standard artist fields if these are empty. When no sort order is specified, +``smartartist+`` is used (but this is configurable). + +You can also specify several sort orders, which will be used in the same order at +which they appear in your query:: + + $ beet list -a genre+ year+ + +This command will sort all albums by genre and, in each genre, in chronological +order. diff --git a/test/_common.py b/test/_common.py index 4b493883a..25cf3a2e2 100644 --- a/test/_common.py +++ b/test/_common.py @@ -85,6 +85,30 @@ def item(lib=None): lib.add(i) return i +_album_ident = 0 +def album(lib=None): + global _item_ident + _item_ident += 1 + i = beets.library.Album( + artpath= None, + albumartist = 'some album artist', + albumartist_sort = 'some sort album artist', + albumartist_credit = 'some album artist credit', + album = 'the album', + genre = 'the genre', + year = 2014, + month = 2, + day = 5, + tracktotal = 0, + disctotal = 1, + comp = False, + mb_albumid = 'someID-1', + mb_albumartistid = 'someID-1' + ) + if lib: + lib.add(i) + return i + # Dummy import session. def import_session(lib=None, logfile=None, paths=[], query=[], cli=False): cls = commands.TerminalImportSession if cli else importer.ImportSession diff --git a/test/test_dbcore.py b/test/test_dbcore.py index a4be181e4..e55bd84db 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -412,6 +412,37 @@ class QueryFromStringsTest(_common.TestCase): self.assertIsInstance(q.subqueries[0], dbcore.query.NumericQuery) +class SortFromStringsTest(_common.TestCase): + def sfs(self, strings): + return dbcore.queryparse.sort_from_strings( + TestModel1, + strings, + ) + + def test_zero_parts(self): + s = self.sfs([]) + self.assertIsNone(s) + + def test_one_parts(self): + s = self.sfs(['field+']) + self.assertIsInstance(s, dbcore.query.Sort) + + def test_two_parts(self): + s = self.sfs(['field+', 'another_field-']) + self.assertIsInstance(s, dbcore.query.MultipleSort) + self.assertEqual(len(s.sorts), 2) + + def test_fixed_field_sort(self): + s = self.sfs(['field_one+']) + self.assertIsInstance(s, dbcore.query.MultipleSort) + self.assertIsInstance(s.sorts[0], dbcore.query.FixedFieldSort) + + 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) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_sort.py b/test/test_sort.py new file mode 100644 index 000000000..76e5a35cb --- /dev/null +++ b/test/test_sort.py @@ -0,0 +1,334 @@ +# This file is part of beets. +# Copyright 2013, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Various tests for querying the library database. +""" +import _common +from _common import unittest +import beets.library +from beets import dbcore + + +# A test case class providing a library with some dummy data and some +# assertions involving that data. +class DummyDataTestCase(_common.TestCase): + def setUp(self): + super(DummyDataTestCase, self).setUp() + self.lib = beets.library.Library(':memory:') + + albums = [_common.album() for _ in range(3)] + albums[0].album = "album A" + albums[0].genre = "Rock" + albums[0].year = "2001" + albums[0].flex1 = "flex1-1" + albums[0].flex2 = "flex2-A" + albums[1].album = "album B" + albums[1].genre = "Rock" + albums[1].year = "2001" + albums[1].flex1 = "flex1-2" + albums[1].flex2 = "flex2-A" + albums[2].album = "album C" + albums[2].genre = "Jazz" + albums[2].year = "2005" + albums[2].flex1 = "flex1-1" + albums[2].flex2 = "flex2-B" + for album in albums: + self.lib.add(album) + + items = [_common.item() for _ in range(4)] + items[0].title = 'foo bar' + items[0].artist = 'one' + items[0].album = 'baz' + items[0].year = 2001 + items[0].comp = True + items[0].flex1 = "flex1-0" + items[0].flex2 = "flex2-A" + items[0].album_id = albums[0].id + items[1].title = 'baz qux' + items[1].artist = 'two' + items[1].album = 'baz' + items[1].year = 2002 + items[1].comp = True + items[1].flex1 = "flex1-1" + items[1].flex2 = "flex2-A" + items[1].album_id = albums[0].id + items[2].title = 'beets 4 eva' + items[2].artist = 'three' + items[2].album = 'foo' + items[2].year = 2003 + items[2].comp = False + items[2].flex1 = "flex1-2" + items[2].flex2 = "flex1-B" + items[2].album_id = albums[1].id + items[3].title = 'beets 4 eva' + items[3].artist = 'three' + items[3].album = 'foo2' + items[3].year = 2004 + items[3].comp = False + items[3].flex1 = "flex1-2" + items[3].flex2 = "flex1-C" + items[3].album_id = albums[2].id + for item in items: + self.lib.add(item) + + +class SortFixedFieldTest(DummyDataTestCase): + def test_sort_asc(self): + q = '' + sort = dbcore.query.FixedFieldSort("year", True) + results = self.lib.items(q, sort) + self.assertLessEqual(results[0]['year'], results[1]['year']) + self.assertEqual(results[0]['year'], 2001) + # same thing with query string + q = 'year+' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_desc(self): + q = '' + sort = dbcore.query.FixedFieldSort("year", False) + results = self.lib.items(q, sort) + self.assertGreaterEqual(results[0]['year'], results[1]['year']) + self.assertEqual(results[0]['year'], 2004) + # same thing with query string + q = 'year-' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_two_field_asc(self): + q = '' + s1 = dbcore.query.FixedFieldSort("album", True) + s2 = dbcore.query.FixedFieldSort("year", True) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.items(q, sort) + self.assertLessEqual(results[0]['album'], results[1]['album']) + self.assertLessEqual(results[1]['album'], results[2]['album']) + self.assertEqual(results[0]['album'], 'baz') + self.assertEqual(results[1]['album'], 'baz') + self.assertLessEqual(results[0]['year'], results[1]['year']) + # same thing with query string + q = 'album+ year+' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +class SortFlexFieldTest(DummyDataTestCase): + def test_sort_asc(self): + q = '' + sort = dbcore.query.FlexFieldSort(beets.library.Item, "flex1", True) + results = self.lib.items(q, sort) + self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) + self.assertEqual(results[0]['flex1'], 'flex1-0') + # same thing with query string + q = 'flex1+' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_desc(self): + q = '' + sort = dbcore.query.FlexFieldSort(beets.library.Item, "flex1", False) + results = self.lib.items(q, sort) + self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) + self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) + self.assertGreaterEqual(results[2]['flex1'], results[3]['flex1']) + self.assertEqual(results[0]['flex1'], 'flex1-2') + # same thing with query string + q = 'flex1-' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + 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) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.items(q, sort) + self.assertGreaterEqual(results[0]['flex2'], results[1]['flex2']) + self.assertGreaterEqual(results[1]['flex2'], results[2]['flex2']) + self.assertEqual(results[0]['flex2'], 'flex2-A') + self.assertEqual(results[1]['flex2'], 'flex2-A') + self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) + # same thing with query string + q = 'flex2- flex1+' + results2 = self.lib.items(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +class SortAlbumFixedFieldTest(DummyDataTestCase): + def test_sort_asc(self): + q = '' + sort = dbcore.query.FixedFieldSort("year", True) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['year'], results[1]['year']) + self.assertEqual(results[0]['year'], 2001) + # same thing with query string + q = 'year+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_desc(self): + q = '' + sort = dbcore.query.FixedFieldSort("year", False) + results = self.lib.albums(q, sort) + self.assertGreaterEqual(results[0]['year'], results[1]['year']) + self.assertEqual(results[0]['year'], 2005) + # same thing with query string + q = 'year-' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_two_field_asc(self): + q = '' + s1 = dbcore.query.FixedFieldSort("genre", True) + s2 = dbcore.query.FixedFieldSort("album", True) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['genre'], results[1]['genre']) + self.assertLessEqual(results[1]['genre'], results[2]['genre']) + self.assertEqual(results[1]['genre'], 'Rock') + self.assertEqual(results[2]['genre'], 'Rock') + self.assertLessEqual(results[1]['album'], results[2]['album']) + # same thing with query string + q = 'genre+ album+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +class SortAlbumFlexdFieldTest(DummyDataTestCase): + def test_sort_asc(self): + q = '' + sort = dbcore.query.FlexFieldSort(beets.library.Album, "flex1", True) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) + self.assertLessEqual(results[1]['flex1'], results[2]['flex1']) + # same thing with query string + q = 'flex1+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_desc(self): + q = '' + sort = dbcore.query.FlexFieldSort(beets.library.Album, "flex1", False) + results = self.lib.albums(q, sort) + self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) + self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) + # same thing with query string + q = 'flex1-' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + 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) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['flex2'], results[1]['flex2']) + self.assertLessEqual(results[1]['flex2'], results[2]['flex2']) + self.assertEqual(results[0]['flex2'], 'flex2-A') + self.assertEqual(results[1]['flex2'], 'flex2-A') + self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) + # same thing with query string + q = 'flex2+ flex1+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +class SortAlbumComputedFieldTest(DummyDataTestCase): + def test_sort_asc(self): + q = '' + sort = dbcore.query.ComputedFieldSort(beets.library.Album, "path", + True) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['path'], results[1]['path']) + self.assertLessEqual(results[1]['path'], results[2]['path']) + # same thing with query string + q = 'path+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_sort_desc(self): + q = '' + sort = dbcore.query.ComputedFieldSort(beets.library.Album, "path", + False) + results = self.lib.albums(q, sort) + self.assertGreaterEqual(results[0]['path'], results[1]['path']) + self.assertGreaterEqual(results[1]['path'], results[2]['path']) + # same thing with query string + q = 'path-' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +class SortCombinedFieldTest(DummyDataTestCase): + def test_computed_first(self): + q = '' + s1 = dbcore.query.ComputedFieldSort(beets.library.Album, "path", True) + s2 = dbcore.query.FixedFieldSort("year", True) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['path'], results[1]['path']) + self.assertLessEqual(results[1]['path'], results[2]['path']) + q = 'path+ year+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + def test_computed_second(self): + q = '' + s1 = dbcore.query.FixedFieldSort("year", True) + s2 = dbcore.query.ComputedFieldSort(beets.library.Album, "path", True) + sort = dbcore.query.MultipleSort() + sort.add_criteria(s1) + sort.add_criteria(s2) + results = self.lib.albums(q, sort) + self.assertLessEqual(results[0]['year'], results[1]['year']) + self.assertLessEqual(results[1]['year'], results[2]['year']) + self.assertLessEqual(results[0]['path'], results[1]['path']) + q = 'year+ path+' + results2 = self.lib.albums(q) + for r1, r2 in zip(results, results2): + self.assertEqual(r1.id, r2.id) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == '__main__': + unittest.main(defaultTest='suite')