diff --git a/beets/__init__.py b/beets/__init__.py index a556c3e44..3e794c068 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.2.2' +__version__ = '1.3.0' __author__ = 'Adrian Sampson ' import beets.library diff --git a/beets/importer.py b/beets/importer.py index a42d2760a..3feaffcb8 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -810,7 +810,7 @@ def plugin_stage(session, func): # Stage may modify DB, so re-load cached item data. for item in task.imported_items(): - session.lib.load(item) + item.load() def manipulate_files(session): """A coroutine (pipeline stage) that performs necessary file @@ -866,7 +866,7 @@ def manipulate_files(session): # Save new paths. with session.lib.transaction(): for item in items: - session.lib.store(item) + item.store() # Plugin event. plugins.send('import_task_files', session=session, task=task) diff --git a/beets/library.py b/beets/library.py index 439df774e..ef6f86e85 100644 --- a/beets/library.py +++ b/beets/library.py @@ -236,59 +236,207 @@ class InvalidFieldError(Exception): # Library items (songs). -class Item(object): - def __init__(self, values): - self.dirty = {} - self._fill_record(values) - self._clear_dirty() +class FlexModel(object): + """An abstract object that consists of a set of "fast" (fixed) + fields and an arbitrary number of flexible fields. + """ + + _fields = () + """The available "fixed" fields on this type. + """ + + def __init__(self, **values): + """Create a new object with the given field values (which may be + fixed or flex fields). + """ + self._dirty = set() + self._values_fixed = {} + self._values_flex = {} + self.update(values) + self.clear_dirty() + + def __repr__(self): + return '{0}({1})'.format( + type(self).__name__, + ', '.join('{0}={1!r}'.format(k, v) for k, v in dict(self).items()), + ) + + def clear_dirty(self): + self._dirty = set() + + + # Act like a dictionary. + + def __getitem__(self, key): + """Get the value for a field. Fixed fields always return a value + (which may be None); flex fields may raise a KeyError. + """ + if key in self._fields: + return self._values_fixed.get(key) + elif key in self._values_flex: + return self._values_flex[key] + else: + raise KeyError(key) + + def __setitem__(self, key, value): + """Assign the value for a field. + """ + source = self._values_fixed if key in self._fields \ + else self._values_flex + old_value = source.get(key) + source[key] = value + if old_value != value: + self._dirty.add(key) + + def update(self, values): + """Assign all values in the given dict. + """ + for key, value in values.items(): + self[key] = value + + def keys(self): + """Get all the keys (both fixed and flex) on this object. + """ + return list(self._fields) + self._values_flex.keys() + + def get(self, key, default=None): + """Get the value for a given key or `default` if it does not + exist. + """ + if key in self: + return self[key] + else: + return default + + def __contains__(self, key): + """Determine whether `key` is a fixed or flex attribute on this + object. + """ + return key in self._fields or key in self._values_flex + + + # Convenient attribute access. + + def __getattr__(self, key): + if key.startswith('_'): + raise AttributeError('model has no attribute {0!r}'.format(key)) + else: + try: + return self[key] + except KeyError: + raise AttributeError('no such field {0!r}'.format(key)) + + def __setattr__(self, key, value): + if key.startswith('_'): + super(FlexModel, self).__setattr__(key, value) + else: + self[key] = value + +class LibModel(FlexModel): + """A model base class that includes a reference to a Library object. + It knows how to load and store itself from the database. + """ + + _table = None + """The main SQLite table name. + """ + + _flex_table = None + """The flex field SQLite table name. + """ + + _bytes_keys = ('path', 'artpath') + """Keys whose values should be stored as raw bytes blobs rather than + strings. + """ + + def __init__(self, lib=None, **values): + self._lib = lib + super(LibModel, self).__init__(**values) + + def _check_db(self): + """Ensure that this object is associated with a database row: it + has a reference to a library (`_lib`) and an id. A ValueError + exception is raised otherwise. + """ + if not self._lib: + raise ValueError('{0} has no library'.format(type(self).__name__)) + if not self.id: + raise ValueError('{0} has no id'.format(type(self).__name__)) + + def store(self): + """Save the object's metadata into the library database. + """ + self._check_db() + + # Build assignments for query. + assignments = '' + subvars = [] + for key in self._fields: + if key != 'id' and key in self._dirty: + assignments += key + '=?,' + value = self[key] + # Wrap path strings in buffers so they get stored + # "in the raw". + if key in self._bytes_keys and isinstance(value, str): + value = buffer(value) + subvars.append(value) + assignments = assignments[:-1] # Knock off last , + + with self._lib.transaction() as tx: + # Main table update. + if assignments: + query = 'UPDATE {0} SET {1} WHERE id=?'.format( + self._table, assignments + ) + subvars.append(self.id) + tx.mutate(query, subvars) + + # Flexible attributes. + for key, value in self._values_flex.items(): + tx.mutate( + 'INSERT INTO {0} ' + '(entity_id, key, value) ' + 'VALUES (?, ?, ?);'.format(self._flex_table), + (self.id, key, value), + ) + + self.clear_dirty() + + def load(self): + """Refresh the object's metadata from the library database. + """ + self._check_db() + + # Get a fresh copy of this object from the DB. + with self._lib.transaction() as tx: + rows = tx.query( + 'SELECT * FROM {0} WHERE id=?;'.format(self._table), + (self.id,) + ) + results = Results(type(self), rows, self._lib) + stored_obj = results.get() + + self.update(dict(stored_obj)) + self.clear_dirty() + +class Item(LibModel): + _fields = ITEM_KEYS + _table = 'items' + _flex_table = 'item_attributes' @classmethod def from_path(cls, path): """Creates a new item from the media file at the specified path. """ # Initiate with values that aren't read from files. - i = cls({ - 'album_id': None, - }) + i = cls(album_id=None) i.read(path) i.mtime = i.current_mtime() # Initial mtime. return i - def _fill_record(self, values): - self.record = {} - for key in ITEM_KEYS: - try: - setattr(self, key, values[key]) - except KeyError: - setattr(self, key, None) - - def _clear_dirty(self): - self.dirty = {} - for key in ITEM_KEYS: - self.dirty[key] = False - - def __repr__(self): - return 'Item(' + repr(self.record) + ')' - - - # Item field accessors. - - def __getattr__(self, key): - """If key is an item attribute (i.e., a column in the database), - returns the record entry for that key. - """ - if key in ITEM_KEYS: - return self.record[key] - else: - raise AttributeError(key + ' is not a valid item field') - - def __setattr__(self, key, value): - """If key is an item attribute (i.e., a column in the database), - sets the record entry for that key to value. Note that to change - the attribute in the database or in the file's tags, one must - call store() or write(). - - Otherwise, performs an ordinary setattr. + def __setitem__(self, key, value): + """Set the item's value for a standard field or a flexattr. """ # Encode unicode paths and read buffers. if key == 'path': @@ -297,15 +445,18 @@ class Item(object): elif isinstance(value, buffer): value = str(value) - if key in ITEM_KEYS: - # If the value changed, mark the field as dirty. - if (key not in self.record) or (self.record[key] != value): - self.record[key] = value - self.dirty[key] = True - if key in ITEM_KEYS_WRITABLE: - self.mtime = 0 # Reset mtime on dirty. - else: - super(Item, self).__setattr__(key, value) + if key in ITEM_KEYS_WRITABLE: + self.mtime = 0 # Reset mtime on dirty. + + super(Item, self).__setitem__(key, value) + + def update(self, values): + """Sett all key/value pairs in the mapping. If mtime is + specified, it is not reset (as it might otherwise be). + """ + super(Item, self).update(values) + if self.mtime == 0 and 'mtime' in values: + self.mtime = values['mtime'] # Interaction with file metadata. @@ -426,6 +577,12 @@ class Item(object): if not mapping['albumartist']: mapping['albumartist'] = mapping['artist'] + # Flexible attributes. + for key, value in self._values_flex.items(): + if sanitize: + value = format_for_path(value, None, pathmod) + mapping[key] = value + # Get values from plugins. for key, value in plugins.template_values(self).items(): if sanitize: @@ -451,11 +608,14 @@ class Query(object): """An abstract class representing a query into the item database. """ def clause(self): - """Returns (clause, subvals) where clause is a valid sqlite + """Generate an SQLite expression implementing the query. + Return a clause string, a sequence of substitution values for + the clause, and a Query object representing the "remainder" + Returns (clause, subvals) where clause is a valid sqlite WHERE clause implementing the query and subvals is a list of items to be substituted for ?s in the clause. """ - raise NotImplementedError + return None, () def match(self, item): """Check whether this query matches a given Item. Can be used to @@ -463,34 +623,27 @@ class Query(object): """ raise NotImplementedError - def statement(self, columns='*'): - """Returns (query, subvals) where clause is a sqlite SELECT - statement to enact this query and subvals is a list of values - to substitute in for ?s in the query. - """ - clause, subvals = self.clause() - return ('SELECT ' + columns + ' FROM items WHERE ' + clause, subvals) - - def count(self, tx): - """Returns `(num, length)` where `num` is the number of items in - the library matching this query and `length` is their total - length in seconds. - """ - clause, subvals = self.clause() - statement = 'SELECT COUNT(id), SUM(length) FROM items WHERE ' + clause - result = tx.query(statement, subvals)[0] - return (result[0], result[1] or 0.0) - class FieldQuery(Query): """An abstract query that searches in a specific field for a pattern. Subclasses must provide a `value_match` class method, which determines whether a certain pattern string matches a certain value - string. Subclasses also need to provide `clause` to implement the + string. Subclasses may also provide `col_clause` to implement the same matching functionality in SQLite. """ - def __init__(self, field, pattern): + def __init__(self, field, pattern, fast=True): self.field = field self.pattern = pattern + self.fast = fast + + def col_clause(self): + return None, () + + def clause(self): + if self.fast: + return self.col_clause() + else: + # Matching a flexattr. This is a slow query. + return None, () @classmethod def value_match(cls, pattern, value): @@ -507,30 +660,11 @@ class FieldQuery(Query): return cls.value_match(pattern, util.as_string(value)) def match(self, item): - return self._raw_value_match(self.pattern, getattr(item, self.field)) - -class RegisteredFieldQuery(FieldQuery): - """A FieldQuery that uses a registered SQLite callback function. - Before it can be used to execute queries, the `register` method must - be called. - """ - def clause(self): - # Invoke the registered SQLite function. - clause = "{name}(?, {field})".format(name=self.__class__.__name__, - field=self.field) - return clause, [self.pattern] - - @classmethod - def register(cls, conn): - """Register this query's matching function with the SQLite - connection. This method should only be invoked when the query - type chooses not to override `clause`. - """ - conn.create_function(cls.__name__, 2, cls._raw_value_match) + return self._raw_value_match(self.pattern, item.get(self.field)) class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" - def clause(self): + def col_clause(self): pattern = self.pattern if self.field == 'path' and isinstance(pattern, str): pattern = buffer(pattern) @@ -544,7 +678,7 @@ class MatchQuery(FieldQuery): class SubstringQuery(FieldQuery): """A query that matches a substring in a specific item field.""" - def clause(self): + def col_clause(self): search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%') .replace('_','\\_')) + '%' clause = self.field + " like ? escape '\\'" @@ -555,7 +689,7 @@ class SubstringQuery(FieldQuery): def value_match(cls, pattern, value): return pattern.lower() in value.lower() -class RegexpQuery(RegisteredFieldQuery): +class RegexpQuery(FieldQuery): """A query that matches a regular expression in a specific item field. """ @@ -601,8 +735,8 @@ class NumericQuery(FieldQuery): except ValueError: return None - def __init__(self, field, pattern): - super(NumericQuery, self).__init__(field, pattern) + def __init__(self, field, pattern, fast=True): + super(NumericQuery, self).__init__(field, pattern, fast) self.numtype = self.kinds[field] parts = pattern.split('..', 1) @@ -631,7 +765,7 @@ class NumericQuery(FieldQuery): return False return True - def clause(self): + def col_clause(self): if self.point is not None: return self.field + '=?', (self.point,) else: @@ -680,6 +814,9 @@ class CollectionQuery(Query): subvals = [] for subq in self.subqueries: subq_clause, subq_subvals = subq.clause() + if not subq_clause: + # Fall back to slow query. + return None, () clause_parts.append('(' + subq_clause + ')') subvals += subq_subvals clause = (' ' + joiner + ' ').join(clause_parts) @@ -727,7 +864,7 @@ class AnyFieldQuery(CollectionQuery): subqueries = [] for field in self.fields: - subqueries.append(cls(field, pattern)) + subqueries.append(cls(field, pattern, True)) super(AnyFieldQuery, self).__init__(subqueries) def clause(self): @@ -787,20 +924,85 @@ class PathQuery(Query): file_blob = buffer(self.file_path) return '(path = ?) || (path LIKE ?)', (file_blob, dir_pat) -class ResultIterator(object): - """An iterator into an item query result set. The iterator lazily - constructs Item objects that reflect database rows. +class Results(object): + """An item query result set. Iterating over the collection lazily + constructs LibModel objects that reflect database rows. """ - def __init__(self, rows): + def __init__(self, model_class, rows, lib, query=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 library `lib`. 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. + """ + self.model_class = model_class self.rows = rows - self.rowiter = iter(self.rows) + self.lib = lib + self.query = query def __iter__(self): - return 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.lib.transaction() as tx: + flex_rows = tx.query( + 'SELECT * FROM {0} WHERE entity_id=?'.format( + self.model_class._flex_table + ), + (row['id'],) + ) + values = dict(row) + values.update({row['key']: row['value'] for row in flex_rows}) - def next(self): - row = self.rowiter.next() # May raise StopIteration. - return Item(row) + # Construct the Python object and yield it if it passes the + # predicate. + obj = self.model_class(self.lib, **values) + if not self.query or self.query.match(obj): + yield obj + + def __len__(self): + """Get the number of matching objects. + """ + if self.query: + # A slow query. Fall back to testing every object. + count = 0 + for obj in self: + count += 1 + return count + + else: + # A fast query. Just count the rows. + return len(self.rows) + + def __nonzero__(self): + """Does this result contain any objects? + """ + return bool(len(self)) + + def __getitem__(self, n): + """Get the nth item in this result set. This is inefficient: all + items up to n are materialized and thrown away. + """ + it = iter(self) + try: + for i in range(n): + it.next() + return it.next() + except StopIteration: + raise IndexError('result index {0} out of range'.format(n)) + + def get(self): + """Return the first matching object, or None if no objects + match. + """ + it = iter(self) + try: + return it.next() + except StopIteration: + return None # Regular expression for parse_query_part, below. PARSE_QUERY_PART_REGEX = re.compile( @@ -874,12 +1076,14 @@ def construct_query_part(query_part, default_fields, all_keys): # Other query type. return query_class(pattern) + key = key.lower() + # A boolean field. - elif key.lower() == 'comp': - return BooleanQuery(key.lower(), pattern) + if key.lower() == 'comp': + return BooleanQuery(key, pattern) # Path field. - elif key.lower() == 'path' and 'path' in all_keys: + elif key == 'path' and 'path' in all_keys: if query_class is SubstringQuery: # By default, use special path matching logic. return PathQuery(pattern) @@ -887,17 +1091,13 @@ def construct_query_part(query_part, default_fields, all_keys): # Specific query type requested. return query_class('path', pattern) - # Other (recognized) field. - elif key.lower() in all_keys: - return query_class(key.lower(), pattern) - # Singleton query (not a real field). - elif key.lower() == 'singleton': + elif key == 'singleton': return SingletonQuery(util.str2bool(pattern)) - # Unrecognized field. + # Other field. else: - log.warn(u'no such field in query: {0}'.format(key)) + return query_class(key.lower(), pattern, key in all_keys) def get_query(val, album=False): """Takes a value which may be None, a query string, a query string @@ -926,151 +1126,7 @@ def get_query(val, album=False): raise ValueError('query must be None or have type Query or str') -# An abstract library. - -class BaseLibrary(object): - """Abstract base class for music libraries, which are loosely - defined as sets of Items. - """ - def __init__(self): - raise NotImplementedError - - - # Basic operations. - - def add(self, item, copy=False): - """Add the item as a new object to the library database. The id - field will be updated; the new id is returned. If copy, then - each item is copied to the destination location before it is - added. - """ - raise NotImplementedError - - def load(self, item, load_id=None): - """Refresh the item's metadata from the library database. If - fetch_id is not specified, use the item's current id. - """ - raise NotImplementedError - - def store(self, item, store_id=None, store_all=False): - """Save the item's metadata into the library database. If - store_id is specified, use it instead of the item's current id. - If store_all is true, save the entire record instead of just - the dirty fields. - """ - raise NotImplementedError - - def remove(self, item): - """Removes the item from the database (leaving the file on - disk). - """ - raise NotImplementedError - - - # Browsing operations. - # Naive implementations are provided, but these methods should be - # overridden if a better implementation exists. - - def _get(self, query=None, default_fields=None): - """Returns a sequence of the items matching query, which may - be None (match the entire library), a Query object, or a query - string. If default_fields is specified, it restricts the fields - that may be matched by unqualified query string terms. - """ - raise NotImplementedError - - def albums(self, artist=None, query=None): - """Returns a sorted list of BaseAlbum objects, possibly filtered - by an artist name or an arbitrary query. Unqualified query - string terms only match fields that apply at an album - granularity: artist, album, and genre. - """ - # Gather the unique album/artist names and associated example - # Items. - specimens = {} - for item in self._get(query, ALBUM_DEFAULT_FIELDS): - if (artist is None or item.artist == artist): - key = (item.artist, item.album) - if key not in specimens: - specimens[key] = item - - # Build album objects. - for k in sorted(specimens.keys()): - item = specimens[k] - record = {} - for key in ALBUM_KEYS_ITEM: - record[key] = getattr(item, key) - yield BaseAlbum(self, record) - - def items(self, artist=None, album=None, title=None, query=None): - """Returns a sequence of the items matching the given artist, - album, title, and query (if present). Sorts in such a way as to - group albums appropriately. Unqualified query string terms only - match intuitively relevant fields: artist, album, genre, title, - and comments. - """ - out = [] - for item in self._get(query, ITEM_DEFAULT_FIELDS): - if (artist is None or item.artist == artist) and \ - (album is None or item.album == album) and \ - (title is None or item.title == title): - out.append(item) - - # Sort by: artist, album, disc, track. - def compare(a, b): - return cmp(a.artist, b.artist) or \ - cmp(a.album, b.album) or \ - cmp(a.disc, b.disc) or \ - cmp(a.track, b.track) - return sorted(out, compare) - -class BaseAlbum(object): - """Represents an album in the library, which in turn consists of a - collection of items in the library. - - This base version just reflects the metadata of the album's items - and therefore isn't particularly useful. The items are referenced - by the record's album and artist fields. Implementations can add - album-level metadata or use distinct backing stores. - """ - def __init__(self, library, record): - super(BaseAlbum, self).__setattr__('_library', library) - super(BaseAlbum, self).__setattr__('_record', record) - - def __getattr__(self, key): - """Get the value for an album attribute.""" - if key in self._record: - return self._record[key] - else: - raise AttributeError('no such field %s' % key) - - def __setattr__(self, key, value): - """Set the value of an album attribute, modifying each of the - album's items. - """ - if key in self._record: - # Reflect change in this object. - self._record[key] = value - # Modify items. - if key in ALBUM_KEYS_ITEM: - items = self._library.items(albumartist=self.albumartist, - album=self.album) - for item in items: - setattr(item, key, value) - self._library.store(item) - else: - super(BaseAlbum, self).__setattr__(key, value) - - def load(self): - """Refresh this album's cached metadata from the library. - """ - items = self._library.items(artist=self.artist, album=self.album) - item = iter(items).next() - for key in ALBUM_KEYS_ITEM: - self._record[key] = getattr(item, key) - - -# Concrete DB-backed library. +# The Library: interface to the database. class Transaction(object): """A context manager for safe, concurrent access to the database. @@ -1124,7 +1180,7 @@ class Transaction(object): """Execute a string containing multiple SQL statements.""" self.lib._connection().executescript(statements) -class Library(BaseLibrary): +class Library(object): """A music library using an SQLite database as a metadata store.""" def __init__(self, path='library.blb', directory='~/Music', @@ -1161,6 +1217,8 @@ class Library(BaseLibrary): # Set up database schema. self._make_table('items', item_fields) self._make_table('albums', album_fields) + self._make_attribute_table('item') + self._make_attribute_table('album') def _make_table(self, table, fields): """Set up the schema of the library file. fields is a list of @@ -1213,6 +1271,22 @@ class Library(BaseLibrary): with self.transaction() as tx: tx.script(setup_sql) + def _make_attribute_table(self, entity): + """Create a table and associated index for flexible attributes + for the given entity (if they don't exist). + """ + with self.transaction() as tx: + tx.script(""" + CREATE TABLE IF NOT EXISTS {0}_attributes ( + id INTEGER PRIMARY KEY, + entity_id INTEGER, + key TEXT, + value TEXT, + UNIQUE(entity_id, key) ON CONFLICT REPLACE); + CREATE INDEX IF NOT EXISTS {0}_id_attribute + ON {0}_attributes (entity_id); + """.format(entity)) + def _connection(self): """Get a SQLite connection object to the underlying database. One connection object is created per thread. @@ -1231,12 +1305,6 @@ class Library(BaseLibrary): # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row - # Register plugin queries. - RegexpQuery.register(conn) - for prefix, query_class in plugins.queries().items(): - if issubclass(query_class, RegisteredFieldQuery): - query_class.register(conn) - self._connections[thread_id] = conn return conn @@ -1329,9 +1397,16 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): + """Add the item as a new object to the library database. The id + field will be updated; the new id is returned. If copy, then + each item is copied to the destination location before it is + added. + """ item.added = time.time() if copy: self.move(item, copy=True) + if not item._lib: + item._lib = self # Build essential parts of query. columns = ','.join([key for key in ITEM_KEYS if key != 'id']) @@ -1345,57 +1420,26 @@ class Library(BaseLibrary): subvars.append(value) # Issue query. - query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' with self.transaction() as tx: - new_id = tx.mutate(query, subvars) + # Main table insertion. + new_id = tx.mutate( + 'INSERT INTO items (' + columns + ') VALUES (' + values + ')', + subvars + ) - item._clear_dirty() + # Flexible attributes. + flexins = 'INSERT INTO item_attributes ' \ + ' (entity_id, key, value)' \ + ' VALUES (?, ?, ?)' + for key, value in item._values_flex.items(): + if value is not None: + tx.mutate(flexins, (new_id, key, value)) + + item.clear_dirty() item.id = new_id self._memotable = {} return new_id - def load(self, item, load_id=None): - if load_id is None: - load_id = item.id - - with self.transaction() as tx: - rows = tx.query('SELECT * FROM items WHERE id=?', (load_id,)) - item._fill_record(rows[0]) - item._clear_dirty() - - def store(self, item, store_id=None, store_all=False): - if store_id is None: - store_id = item.id - - # Build assignments for query. - assignments = '' - subvars = [] - for key in ITEM_KEYS: - if (key != 'id') and (item.dirty[key] or store_all): - assignments += key + '=?,' - value = getattr(item, key) - # Wrap path strings in buffers so they get stored - # "in the raw". - if key == 'path' and isinstance(value, str): - value = buffer(value) - subvars.append(value) - - if not assignments: - # nothing to store (i.e., nothing was dirty) - return - - assignments = assignments[:-1] # Knock off last , - - # Finish the query. - query = 'UPDATE items SET ' + assignments + ' WHERE id=?' - subvars.append(store_id) - - with self.transaction() as tx: - tx.mutate(query, subvars) - item._clear_dirty() - - self._memotable = {} - def remove(self, item, delete=False, with_album=True): """Removes this item. If delete, then the associated file is removed from disk. If with_album, then the item's album (if any) @@ -1406,13 +1450,9 @@ class Library(BaseLibrary): with self.transaction() as tx: tx.mutate('DELETE FROM items WHERE id=?', (item.id,)) - if album: - item_iter = album.items() - try: - item_iter.next() - except StopIteration: - # Album is empty. - album.remove(delete, False) + # Remove the album if it is empty. + if album and not album.items(): + album.remove(delete, False) if delete: util.remove(item.path) @@ -1450,13 +1490,14 @@ class Library(BaseLibrary): old_path = item.path item.move(dest, copy) if item.id is not None: - self.store(item) + item.store() # If this item is in an album, move its art. if with_album: album = self.get_album(item) if album: album.move_art(copy) + album.store() # Prune vacated directory. if not copy: @@ -1466,20 +1507,36 @@ class Library(BaseLibrary): # Querying. def albums(self, query=None, artist=None): + """Returns a sorted list of Album objects, possibly filtered + by an artist name or an arbitrary query. Unqualified query + string terms only match fields that apply at an album + granularity: artist, album, and genre. + """ query = get_query(query, True) if artist is not None: # "Add" the artist to the query. query = AndQuery((query, MatchQuery('albumartist', artist))) + where, subvals = query.clause() - sql = "SELECT * FROM albums " + \ - "WHERE " + where + \ - " ORDER BY %s, album" % \ - _orelse("albumartist_sort", "albumartist") with self.transaction() as tx: - rows = tx.query(sql, subvals) - return [Album(self, dict(res)) for res in rows] + rows = tx.query( + "SELECT * FROM albums WHERE {0} " + "ORDER BY {1}, album".format( + where or '1', + _orelse("albumartist_sort", "albumartist"), + ), + subvals, + ) + + return Results(Album, rows, self, None if where else query) def items(self, query=None, artist=None, album=None, title=None): + """Returns a sequence of the items matching the given artist, + album, title, and query (if present). Sorts in such a way as to + group albums appropriately. Unqualified query string terms only + match intuitively relevant fields: artist, album, genre, title, + and comments. + """ queries = [get_query(query, False)] if artist is not None: queries.append(MatchQuery('artist', artist)) @@ -1487,17 +1544,20 @@ class Library(BaseLibrary): queries.append(MatchQuery('album', album)) if title is not None: queries.append(MatchQuery('title', title)) - super_query = AndQuery(queries) - where, subvals = super_query.clause() + query = AndQuery(queries) + where, subvals = query.clause() - sql = "SELECT * FROM items " + \ - "WHERE " + where + \ - " ORDER BY %s, album, disc, track" % \ - _orelse("artist_sort", "artist") - log.debug('Getting items with SQL: %s' % sql) with self.transaction() as tx: - rows = tx.query(sql, subvals) - return ResultIterator(rows) + rows = tx.query( + "SELECT * FROM items WHERE {0} " + "ORDER BY {1}, album, disc, track".format( + where or '1', + _orelse("artist_sort", "artist"), + ), + subvals + ) + + return Results(Item, rows, self, None if where else query) # Convenience accessors. @@ -1505,13 +1565,7 @@ class Library(BaseLibrary): def get_item(self, id): """Fetch an Item by its ID. Returns None if no match is found. """ - with self.transaction() as tx: - rows = tx.query("SELECT * FROM items WHERE id=?", (id,)) - it = ResultIterator(rows) - try: - return it.next() - except StopIteration: - return None + return self.items(MatchQuery('id', id)).get() def get_album(self, item_or_id): """Given an album ID or an item associated with an album, @@ -1525,13 +1579,7 @@ class Library(BaseLibrary): if album_id is None: return None - with self.transaction() as tx: - rows = tx.query( - 'SELECT * FROM albums WHERE id=?', - (album_id,) - ) - if rows: - return Album(self, dict(rows[0])) + return self.albums(MatchQuery('id', album_id)).get() def add_album(self, items): """Create a new album in the database with metadata derived @@ -1559,79 +1607,36 @@ class Library(BaseLibrary): if item.id is None: self.add(item) else: - self.store(item) + item.store() # Construct the new Album object. - record = {} - for key in ALBUM_KEYS: - # Unset (non-item) fields default to None. - record[key] = album_values.get(key) - record['id'] = album_id - album = Album(self, record) - + album_values['id'] = album_id + album = Album(self, **album_values) return album -class Album(BaseAlbum): +class Album(LibModel): """Provides access to information about albums stored in a library. Reflects the library's "albums" table, including album art. """ - def __init__(self, lib, record): - # Decode Unicode paths in database. - if 'artpath' in record and isinstance(record['artpath'], unicode): - record['artpath'] = bytestring_path(record['artpath']) - super(Album, self).__init__(lib, record) + _fields = ALBUM_KEYS + _table = 'albums' + _flex_table = 'album_attributes' - def __setattr__(self, key, value): + def __setitem__(self, key, value): """Set the value of an album attribute.""" - if key == 'id': - raise AttributeError("can't modify album id") - - elif key in ALBUM_KEYS: - # Make sure paths are bytestrings. - if key == 'artpath' and isinstance(value, unicode): + if key == 'artpath': + if isinstance(value, unicode): value = bytestring_path(value) - - # Reflect change in this object. - self._record[key] = value - - # Store art path as a buffer. - if key == 'artpath' and isinstance(value, str): - value = buffer(value) - - # Change album table. - sql = 'UPDATE albums SET %s=? WHERE id=?' % key - with self._library.transaction() as tx: - tx.mutate(sql, (value, self.id)) - - # Possibly make modification on items as well. - if key in ALBUM_KEYS_ITEM: - for item in self.items(): - setattr(item, key, value) - self._library.store(item) - - else: - object.__setattr__(self, key, value) - - def __getattr__(self, key): - value = super(Album, self).__getattr__(key) - - # Unwrap art path from buffer object. - if key == 'artpath' and isinstance(value, buffer): - value = str(value) - - return value + elif isinstance(value, buffer): + value = bytes(value) + super(Album, self).__setitem__(key, value) def items(self): """Returns an iterable over the items associated with this album. """ - with self._library.transaction() as tx: - rows = tx.query( - 'SELECT * FROM items WHERE album_id=? ORDER BY track', - (self.id,) - ) - return ResultIterator(rows) + return self._lib.items(MatchQuery('album_id', self.id)) def remove(self, delete=False, with_items=True): """Removes this album and all its associated items from the @@ -1646,11 +1651,11 @@ class Album(BaseAlbum): if artpath: util.remove(artpath) - with self._library.transaction() as tx: + with self._lib.transaction() as tx: if with_items: # Remove items. for item in self.items(): - self._library.remove(item, delete, False) + self._lib.remove(item, delete, False) # Remove album from database. tx.mutate( @@ -1681,30 +1686,35 @@ class Album(BaseAlbum): # Prune old path when moving. if not copy: util.prune_dirs(os.path.dirname(old_art), - self._library.directory) + self._lib.directory) def move(self, copy=False, basedir=None): - """Moves (or copies) all items to their destination. Any album + """Moves (or copies) all items to their destination. Any album art moves along with them. basedir overrides the library base - directory for the destination. + directory for the destination. The album is stored to the + database, persisting any modifications to its metadata. """ - basedir = basedir or self._library.directory + basedir = basedir or self._lib.directory + + # Ensure new metadata is available to items for destination + # computation. + self.store() # Move items. items = list(self.items()) for item in items: - self._library.move(item, copy, basedir=basedir, with_album=False) + self._lib.move(item, copy, basedir=basedir, with_album=False) # Move art. self.move_art(copy) + self.store() def item_dir(self): """Returns the directory containing the album's first item, provided that such an item exists. """ - try: - item = self.items().next() - except StopIteration: + item = self.items().get() + if not item: raise ValueError('empty album') return os.path.dirname(item.path) @@ -1723,7 +1733,7 @@ class Album(BaseAlbum): filename_tmpl = Template(beets.config['art_filename'].get(unicode)) subpath = format_for_path(self.evaluate_template(filename_tmpl)) subpath = util.sanitize_path(subpath, - replacements=self._library.replacements) + replacements=self._lib.replacements) subpath = bytestring_path(subpath) _, ext = os.path.splitext(image) @@ -1763,8 +1773,8 @@ class Album(BaseAlbum): """ # Get template field values. mapping = {} - for key in ALBUM_KEYS: - mapping[key] = format_for_path(getattr(self, key), key) + for key, value in dict(self).items(): + mapping[key] = format_for_path(value, key) mapping['artpath'] = displayable_path(mapping['artpath']) mapping['path'] = displayable_path(self.item_dir()) @@ -1780,6 +1790,24 @@ class Album(BaseAlbum): # Perform substitution. return template.substitute(mapping, funcs) + def store(self): + """Update the database with the album information. The album's + tracks are also updated. + """ + # Get modified track fields. + track_updates = {} + for key in ALBUM_KEYS_ITEM: + if key in self._dirty: + track_updates[key] = self[key] + + with self._lib.transaction(): + super(Album, self).store() + if track_updates: + for item in self.items(): + for key, value in track_updates.items(): + item[key] = value + item.store() + # Default path template resources. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index e2cc4e77a..70f59c5f4 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -905,7 +905,7 @@ def update_items(lib, query, album, move, pretend): continue # Read new data. - old_data = dict(item.record) + old_data = dict(item) try: item.read() except Exception as exc: @@ -920,12 +920,12 @@ def update_items(lib, query, album, move, pretend): old_data['albumartist'] == old_data['artist'] == \ item.artist: item.albumartist = old_data['albumartist'] - item.dirty['albumartist'] = False + item._dirty.remove('albumartist') # Get and save metadata changes. changes = {} for key in library.ITEM_KEYS_META: - if item.dirty[key]: + if key in item._dirty: changes[key] = old_data[key], getattr(item, key) if changes: # Something changed. @@ -941,14 +941,14 @@ def update_items(lib, query, album, move, pretend): if move and lib.directory in ancestry(item.path): lib.move(item) - lib.store(item) + item.store() affected_albums.add(item.album_id) elif not pretend: # The file's mtime was different, but there were no changes # to the metadata. Store the new mtime, which is set in the # call to read(), so we don't check this again in the # future. - lib.store(item) + item.store() # Skip album changes while pretending. if pretend: @@ -959,17 +959,18 @@ def update_items(lib, query, album, move, pretend): if album_id is None: # Singletons. continue album = lib.get_album(album_id) - if not album: # Empty albums have already been removed. + if not album: # Empty albums have already been removed. log.debug('emptied album %i' % album_id) continue - al_items = list(album.items()) + first_item = album.items().get() # Update album structure to reflect an item in it. for key in library.ALBUM_KEYS_ITEM: - setattr(album, key, getattr(al_items[0], key)) + album[key] = first_item[key] + album.store() # Move album art (and any inconsistent items). - if move and lib.directory in ancestry(al_items[0].path): + if move and lib.directory in ancestry(first_item.path): log.debug('moving album %i' % album_id) album.move() @@ -1099,6 +1100,8 @@ def _convert_type(key, value, album=False): `album` indicates whether to use album or item field definitions. """ fields = library.ALBUM_FIELDS if album else library.ITEM_FIELDS + if key not in fields: + return value typ = [f[1] for f in fields if f[0] == key][0] if typ is bool: @@ -1120,15 +1123,9 @@ def _convert_type(key, value, album=False): def modify_items(lib, mods, query, write, move, album, confirm): """Modifies matching items according to key=value assignments.""" # Parse key=value specifications into a dictionary. - if album: - allowed_keys = library.ALBUM_KEYS - else: - allowed_keys = library.ITEM_KEYS_WRITABLE + ['added'] fsets = {} for mod in mods: key, value = mod.split('=', 1) - if key not in allowed_keys: - raise ui.UserError('"%s" is not a valid field' % key) fsets[key] = _convert_type(key, value, album) # Get the items to modify. @@ -1143,8 +1140,7 @@ def modify_items(lib, mods, query, write, move, album, confirm): # Show each change. for field, value in fsets.iteritems(): - curval = getattr(obj, field) - _showdiff(field, curval, value) + _showdiff(field, obj.get(field), value) # Confirm. if confirm: @@ -1156,7 +1152,7 @@ def modify_items(lib, mods, query, write, move, album, confirm): with lib.transaction(): for obj in objs: for field, value in fsets.iteritems(): - setattr(obj, field, value) + obj[field] = value if move: cur_path = obj.item_dir() if album else obj.path @@ -1167,9 +1163,7 @@ def modify_items(lib, mods, query, write, move, album, confirm): else: lib.move(obj) - # When modifying items, we have to store them to the database. - if not album: - lib.store(obj) + obj.store() # Apply tags if requested. if write: @@ -1226,7 +1220,7 @@ def move_items(lib, dest, query, copy, album): obj.move(copy, basedir=dest) else: lib.move(obj, copy, basedir=dest) - lib.store(obj) + obj.store() move_cmd = ui.Subcommand('move', help='move or copy items', aliases=('mv',)) diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 6d7940554..ef00c1c31 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -29,7 +29,6 @@ import beets from beets.plugins import BeetsPlugin import beets.ui from beets import vfs -from beets import config from beets.util import bluelet from beets.library import ITEM_KEYS_WRITABLE @@ -931,12 +930,12 @@ class Server(BaseServer): def cmd_stats(self, conn): """Sends some statistics about the library.""" with self.lib.transaction() as tx: - songs, totaltime = beets.library.TrueQuery().count(tx) - statement = 'SELECT COUNT(DISTINCT artist), ' \ - 'COUNT(DISTINCT album) FROM items' - result = tx.query(statement)[0] - artists, albums = result[0], result[1] + 'COUNT(DISTINCT album), ' \ + 'COUNT(id), ' \ + 'SUM(length) ' \ + 'FROM items' + artists, albums, songs, totaltime = tx.query(statement)[0] yield (u'artists: ' + unicode(artists), u'albums: ' + unicode(albums), @@ -1046,8 +1045,11 @@ class Server(BaseServer): tag/value query. """ _, key = self._tagtype_lookup(tag) - query = beets.library.MatchQuery(key, value) - songs, playtime = query.count(self.lib) + songs = 0 + playtime = 0.0 + for item in self.lib.items(beets.library.MatchQuery(key, value)): + songs += 1 + playtime += item.length yield u'songs: ' + unicode(songs) yield u'playtime: ' + unicode(int(playtime)) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 83f67c0a9..084540269 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -160,7 +160,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): help='generate fingerprints for items without them') def fingerprint_cmd_func(lib, opts, args): for item in lib.items(ui.decargs(args)): - fingerprint_item(item, lib=lib, + fingerprint_item(item, write=config['import']['write'].get(bool)) fingerprint_cmd.func = fingerprint_cmd_func @@ -237,12 +237,12 @@ def submit_items(userkey, items, chunksize=64): submit_chunk() -def fingerprint_item(item, lib=None, write=False): +def fingerprint_item(item, write=False): """Get the fingerprint for an Item. If the item already has a fingerprint, it is not regenerated. If fingerprint generation fails, - return None. If `lib` is provided, then new fingerprints are saved - to the database. If `write` is set, then the new fingerprints are - also written to files' metadata. + return None. If the items are associated with a library, they are + saved to the database. If `write` is set, then the new fingerprints + are also written to files' metadata. """ # Get a fingerprint and length for this track. if not item.length: @@ -271,8 +271,8 @@ def fingerprint_item(item, lib=None, write=False): util.displayable_path(item.path) )) item.write() - if lib: - lib.store(item) + if item._lib: + item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info( diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1bb81ae4f..6beaabbf9 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -120,7 +120,7 @@ def convert_item(lib, dest_dir, keep_new, path_formats): # writing) to get new bitrate, duration, etc. if keep_new: item.read() - lib.store(item) # Store new path and audio data. + item.store() # Store new path and audio data. if config['convert']['embed']: album = lib.get_album(item) @@ -142,7 +142,7 @@ def convert_on_import(lib, item): item.path = dest item.write() item.read() # Load new audio information data. - lib.store(item) + item.store() def convert_func(lib, opts, args): @@ -168,7 +168,7 @@ def convert_func(lib, opts, args): if opts.album: items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: - items = lib.items(ui.decargs(args)) + items = iter(lib.items(ui.decargs(args))) convert = [convert_item(lib, dest, keep_new, path_formats) for i in range(threads)] pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py index d3a55d213..d5026c021 100644 --- a/beetsplug/echonest_tempo.py +++ b/beetsplug/echonest_tempo.py @@ -54,7 +54,7 @@ def fetch_item_tempo(lib, loglevel, item, write): item.bpm = tempo if write: item.write() - lib.store(item) + item.store() def get_tempo(artist, title): """Get the tempo for a song.""" diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 15986c8d1..61361f169 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -216,6 +216,7 @@ def batch_fetch_art(lib, albums, force, maxwidth=None): if path: album.set_art(path, False) + album.store() message = 'found album art' else: message = 'no art found' @@ -274,6 +275,7 @@ class FetchArtPlugin(BeetsPlugin): src_removed = config['import']['delete'].get(bool) or \ config['import']['move'].get(bool) album.set_art(path, not src_removed) + album.store() if src_removed: task.prune(path) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index b6ad90d87..21d1c38e2 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -16,12 +16,12 @@ """ from beets.plugins import BeetsPlugin -from beets.library import RegisteredFieldQuery +from beets.library import FieldQuery import beets import difflib -class FuzzyQuery(RegisteredFieldQuery): +class FuzzyQuery(FieldQuery): @classmethod def value_match(self, pattern, val): # smartcase diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 9af015f73..dc42bd29f 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -70,12 +70,10 @@ def compile_inline(python_code, album): is_expr = True def _dict_for(obj): + out = dict(obj) if album: - out = dict(obj._record) out['items'] = list(obj.items()) - return out - else: - return dict(obj.record) + return out if is_expr: # For expressions, just evaluate and return the result. diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index e26ade1fc..dbfa92224 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -335,7 +335,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # track on the album. if 'track' in self.sources: item.genre, src = self._get_genre(item) - lib.store(item) + item.store() log.info(u'genre for track {0} - {1} ({2}): {3}'.format( item.artist, item.title, src, item.genre )) @@ -353,17 +353,18 @@ class LastGenrePlugin(plugins.BeetsPlugin): album.genre, src = self._get_genre(album) log.debug(u'added last.fm album genre ({0}): {1}'.format( src, album.genre)) + album.store() if 'track' in self.sources: for item in album.items(): item.genre, src = self._get_genre(item) log.debug(u'added last.fm item genre ({0}): {1}'.format( src, item.genre)) - session.lib.store(item) + item.store() else: item = task.item item.genre, src = self._get_genre(item) log.debug(u'added last.fm item genre ({0}): {1}'.format( src, item.genre)) - session.lib.store(item) + item.store() diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index bc71a5a89..a6d5b7e7a 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -444,7 +444,7 @@ class LyricsPlugin(BeetsPlugin): if write: item.write() - lib.store(item) + item.store() def get_lyrics(self, artist, title): """Fetch lyrics, trying each source in turn. Return a string or diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 81e802a33..4ffe9a02e 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -24,14 +24,14 @@ from beets import config log = logging.getLogger('beets') -def _print_and_apply_changes(lib, item, move, pretend, write): +def _print_and_apply_changes(lib, item, old_data, move, pretend, write): """Apply changes to an Item and preview them in the console. Return a boolean indicating whether any changes were made. """ changes = {} for key in library.ITEM_KEYS_META: - if item.dirty[key]: - changes[key] = item.old_data[key], getattr(item, key) + if key in item._dirty: + changes[key] = old_data[key], getattr(item, key) if not changes: return False @@ -53,7 +53,7 @@ def _print_and_apply_changes(lib, item, move, pretend, write): log.error(u'could not sync {0}: {1}'.format( util.displayable_path(item.path), exc)) return False - lib.store(item) + item.store() return True @@ -69,7 +69,7 @@ def mbsync_singletons(lib, query, move, pretend, write): .format(s.title)) continue - s.old_data = dict(s.record) + old_data = dict(s) # Get the MusicBrainz recording info. track_info = hooks.track_for_mbid(s.mb_trackid) @@ -80,7 +80,7 @@ def mbsync_singletons(lib, query, move, pretend, write): # Apply. with lib.transaction(): autotag.apply_item_metadata(s, track_info) - _print_and_apply_changes(lib, s, move, pretend, write) + _print_and_apply_changes(lib, s, old_data, move, pretend, write) def mbsync_albums(lib, query, move, pretend, write): @@ -93,8 +93,7 @@ def mbsync_albums(lib, query, move, pretend, write): continue items = list(a.items()) - for item in items: - item.old_data = dict(item.record) + old_data = {item: dict(item) for item in items} # Get the MusicBrainz album information. album_info = hooks.album_for_mbid(a.mb_albumid) @@ -116,8 +115,8 @@ def mbsync_albums(lib, query, move, pretend, write): autotag.apply_metadata(album_info, mapping) changed = False for item in items: - changed = _print_and_apply_changes(lib, item, move, pretend, - write) or changed + changed |= _print_and_apply_changes(lib, item, old_data[item], + move, pretend, write) if not changed: # No change to any item. continue diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 90289cf07..0b12ebde6 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -61,39 +61,41 @@ def _item(track_info, album_info, album_id): t = track_info a = album_info - return Item({'album_id': album_id, - 'album': a.album, - 'albumartist': a.artist, - 'albumartist_credit': a.artist_credit, - 'albumartist_sort': a.artist_sort, - 'albumdisambig': a.albumdisambig, - 'albumstatus': a.albumstatus, - 'albumtype': a.albumtype, - 'artist': t.artist, - 'artist_credit': t.artist_credit, - 'artist_sort': t.artist_sort, - 'asin': a.asin, - 'catalognum': a.catalognum, - 'comp': a.va, - 'country': a.country, - 'day': a.day, - 'disc': t.medium, - 'disctitle': t.disctitle, - 'disctotal': a.mediums, - 'label': a.label, - 'language': a.language, - 'length': t.length, - 'mb_albumid': a.album_id, - 'mb_artistid': t.artist_id, - 'mb_releasegroupid': a.releasegroup_id, - 'mb_trackid': t.track_id, - 'media': a.media, - 'month': a.month, - 'script': a.script, - 'title': t.title, - 'track': t.index, - 'tracktotal': len(a.tracks), - 'year': a.year}) + return Item( + album_id = album_id, + album = a.album, + albumartist = a.artist, + albumartist_credit = a.artist_credit, + albumartist_sort = a.artist_sort, + albumdisambig = a.albumdisambig, + albumstatus = a.albumstatus, + albumtype = a.albumtype, + artist = t.artist, + artist_credit = t.artist_credit, + artist_sort = t.artist_sort, + asin = a.asin, + catalognum = a.catalognum, + comp = a.va, + country = a.country, + day = a.day, + disc = t.medium, + disctitle = t.disctitle, + disctotal = a.mediums, + label = a.label, + language = a.language, + length = t.length, + mb_albumid = a.album_id, + mb_artistid = t.artist_id, + mb_releasegroupid = a.releasegroup_id, + mb_trackid = t.track_id, + media = a.media, + month = a.month, + script = a.script, + title = t.title, + track = t.index, + tracktotal = len(a.tracks), + year = a.year, + ) class MissingPlugin(BeetsPlugin): diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ba6bae862..6b4c2f407 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -225,7 +225,7 @@ class ReplayGainPlugin(BeetsPlugin): for item, info in zip(items, rgain_infos): item.rg_track_gain = info['gain'] item.rg_track_peak = info['peak'] - lib.store(item) + item.store() log.debug(u'replaygain: applied track gain {0}, peak {1}'.format( item.rg_track_gain, @@ -241,3 +241,4 @@ class ReplayGainPlugin(BeetsPlugin): album.rg_album_gain, album.rg_album_peak )) + album.store() diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 5fc518f29..ac2372c58 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -30,7 +30,7 @@ def _rep(obj, expand=False): included. """ if isinstance(obj, beets.library.Item): - out = dict(obj.record) + out = dict(obj) del out['path'] # Get the size (in bytes) of the backing file. This is useful diff --git a/docs/changelog.rst b/docs/changelog.rst index 769115797..d2df94cae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,39 @@ Changelog ========= +1.3.0 (in development) +---------------------- + +Albums and items now have **flexible attributes**. This means that, when you +want to store information about your music in the beets database, you're no +longer constrained to the set of fields it supports out of the box (title, +artist, track, etc.). Instead, you can use any field name you can think of and +treat it just like the built-in fields. + +For example, you can use the :ref:`modify-cmd` command to set a new field on a +track:: + + $ beet modify mood=sexy artist:miguel + +and then query your music based on that field:: + + $ beet ls mood:sunny + +or use templates to see the value of the field:: + + $ beet ls -f '$title: $mood' + +While this feature is nifty when used directly with the usual command-line +suspects, it's especially useful for plugin authors and for future beets +features. Stay tuned for great things built on this flexible attribute +infrastructure. + +One side effect of this change: queries that include unknown fields will now +match *nothing* instead of *everything*. So if you type ``beet ls +fieldThatDoesNotExist:foo``, beets will now return no results, whereas +previous versions would spit out a warning and then list your entire library. + + 1.2.2 (August 27, 2013) ----------------------- diff --git a/docs/conf.py b/docs/conf.py index 4a9e7f2b8..3a646837d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,8 @@ master_doc = 'index' project = u'beets' copyright = u'2012, Adrian Sampson' -version = '1.2' -release = '1.2.2' +version = '1.3' +release = '1.3.0' pygments_style = 'sphinx' diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index 27feace65..186a7689b 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -357,18 +357,18 @@ To do so, define a subclass of the ``Query`` type from the ``beets.library`` module. Then, in the ``queries`` method of your plugin class, return a dictionary mapping prefix strings to query classes. -One simple kind of query you can extend is the ``RegisteredFieldQuery``, which -implements string comparisons. To use it, create a subclass inheriting from -that class and override the ``value_match`` class method. (Remember the -``@classmethod`` decorator!) The following example plugin declares a query -using the ``@`` prefix to delimit exact string matches. The plugin will be -used if we issue a command like ``beet ls @something`` or ``beet ls -artist:@something``:: +One simple kind of query you can extend is the ``FieldQuery``, which +implements string comparisons on fields. To use it, create a subclass +inheriting from that class and override the ``value_match`` class method. +(Remember the ``@classmethod`` decorator!) The following example plugin +declares a query using the ``@`` prefix to delimit exact string matches. The +plugin will be used if we issue a command like ``beet ls @something`` or +``beet ls artist:@something``:: from beets.plugins import BeetsPlugin - from beets.library import PluginQuery + from beets.library import FieldQuery - class ExactMatchQuery(PluginQuery): + class ExactMatchQuery(FieldQuery): @classmethod def value_match(self, pattern, val): return pattern == val diff --git a/setup.py b/setup.py index f5f5e9347..d6201eba1 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.2.2', + version='1.3.0', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', diff --git a/test/_common.py b/test/_common.py index 864813766..f4b4fe37c 100644 --- a/test/_common.py +++ b/test/_common.py @@ -45,35 +45,35 @@ _item_ident = 0 def item(): global _item_ident _item_ident += 1 - return beets.library.Item({ - 'title': u'the title', - 'artist': u'the artist', - 'albumartist': u'the album artist', - 'album': u'the album', - 'genre': u'the genre', - 'composer': u'the composer', - 'grouping': u'the grouping', - 'year': 1, - 'month': 2, - 'day': 3, - 'track': 4, - 'tracktotal': 5, - 'disc': 6, - 'disctotal': 7, - 'lyrics': u'the lyrics', - 'comments': u'the comments', - 'bpm': 8, - 'comp': True, - 'path': 'somepath' + str(_item_ident), - 'length': 60.0, - 'bitrate': 128000, - 'format': 'FLAC', - 'mb_trackid': 'someID-1', - 'mb_albumid': 'someID-2', - 'mb_artistid': 'someID-3', - 'mb_albumartistid': 'someID-4', - 'album_id': None, - }) + return beets.library.Item( + title = u'the title', + artist = u'the artist', + albumartist = u'the album artist', + album = u'the album', + genre = u'the genre', + composer = u'the composer', + grouping = u'the grouping', + year = 1, + month = 2, + day = 3, + track = 4, + tracktotal = 5, + disc = 6, + disctotal = 7, + lyrics = u'the lyrics', + comments = u'the comments', + bpm = 8, + comp = True, + path = 'somepath' + str(_item_ident), + length = 60.0, + bitrate = 128000, + format = 'FLAC', + mb_trackid = 'someID-1', + mb_albumid = 'someID-2', + mb_artistid = 'someID-3', + mb_albumartistid = 'someID-4', + album_id = None, + ) # Dummy import session. def import_session(lib=None, logfile=None, paths=[], query=[], cli=False): diff --git a/test/rsrc/test.blb b/test/rsrc/test.blb index 22c621da5..a2e441c5f 100644 Binary files a/test/rsrc/test.blb and b/test/rsrc/test.blb differ diff --git a/test/test_autotag.py b/test/test_autotag.py index f0b637eb6..20fe20841 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -53,30 +53,30 @@ class PluralityTest(_common.TestCase): plurality([]) def test_current_metadata_finds_pluralities(self): - items = [Item({'artist': 'The Beetles', 'album': 'The White Album'}), - Item({'artist': 'The Beatles', 'album': 'The White Album'}), - Item({'artist': 'The Beatles', 'album': 'Teh White Album'})] + items = [Item(artist='The Beetles', album='The White Album'), + Item(artist='The Beatles', album='The White Album'), + Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertFalse(consensus['artist']) def test_current_metadata_artist_consensus(self): - items = [Item({'artist': 'The Beatles', 'album': 'The White Album'}), - Item({'artist': 'The Beatles', 'album': 'The White Album'}), - Item({'artist': 'The Beatles', 'album': 'Teh White Album'})] + items = [Item(artist='The Beatles', album='The White Album'), + Item(artist='The Beatles', album='The White Album'), + Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertTrue(consensus['artist']) def test_albumartist_consensus(self): - items = [Item({'artist': 'tartist1', 'album': 'album', - 'albumartist': 'aartist'}), - Item({'artist': 'tartist2', 'album': 'album', - 'albumartist': 'aartist'}), - Item({'artist': 'tartist3', 'album': 'album', - 'albumartist': 'aartist'})] + items = [Item(artist='tartist1', album='album', + albumartist='aartist'), + Item(artist='tartist2', album='album', + albumartist='aartist'), + Item(artist='tartist3', album='album', + albumartist='aartist')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'aartist') self.assertFalse(consensus['artist']) @@ -85,19 +85,17 @@ class PluralityTest(_common.TestCase): fields = ['artist', 'album', 'albumartist', 'year', 'disctotal', 'mb_albumid', 'label', 'catalognum', 'country', 'media', 'albumdisambig'] - items = [Item(dict((f, '%s_%s' % (f, i or 1)) for f in fields)) + items = [Item(**dict((f, '%s_%s' % (f, i or 1)) for f in fields)) for i in range(5)] likelies, _ = match.current_metadata(items) for f in fields: self.assertEqual(likelies[f], '%s_1' % f) def _make_item(title, track, artist=u'some artist'): - return Item({ - 'title': title, 'track': track, - 'artist': artist, 'album': u'some album', - 'length': 1, - 'mb_trackid': '', 'mb_albumid': '', 'mb_artistid': '', - }) + return Item(title=title, track=track, + artist=artist, album=u'some album', + length=1, + mb_trackid='', mb_albumid='', mb_artistid='') def _make_trackinfo(): return [ @@ -560,10 +558,10 @@ class MultiDiscAlbumsInDirTest(_common.TestCase): class AssignmentTest(unittest.TestCase): def item(self, title, track): - return Item({ - 'title': title, 'track': track, - 'mb_trackid': '', 'mb_albumid': '', 'mb_artistid': '', - }) + return Item( + title=title, track=track, + mb_trackid='', mb_albumid='', mb_artistid='', + ) def test_reorder_when_track_numbers_incorrect(self): items = [] @@ -640,14 +638,14 @@ class AssignmentTest(unittest.TestCase): def test_order_works_when_track_names_are_entirely_wrong(self): # A real-world test case contributed by a user. def item(i, length): - return Item({ - 'artist': u'ben harper', - 'album': u'burn to shine', - 'title': u'ben harper - Burn to Shine ' + str(i), - 'track': i, - 'length': length, - 'mb_trackid': '', 'mb_albumid': '', 'mb_artistid': '', - }) + return Item( + artist=u'ben harper', + album=u'burn to shine', + title=u'ben harper - Burn to Shine ' + str(i), + track=i, + length=length, + mb_trackid='', mb_albumid='', mb_artistid='', + ) items = [] items.append(item(1, 241.37243007106997)) items.append(item(2, 342.27781704375036)) diff --git a/test/test_db.py b/test/test_db.py index 285a136be..0647a9b8c 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -40,8 +40,8 @@ def remove_lib(): if os.path.exists(TEMP_LIB): os.unlink(TEMP_LIB) def boracay(l): - return beets.library.Item( - l._connection().execute('select * from items where id=3').fetchone() + return beets.library.Item(l, + **l._connection().execute('select * from items where id=3').fetchone() ) np = util.normpath @@ -56,13 +56,13 @@ class LoadTest(unittest.TestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = 'something' - self.lib.load(self.i) + self.i.load() self.assertEqual(original_title, self.i.title) def test_load_clears_dirty_flags(self): self.i.artist = 'something' - self.lib.load(self.i) - self.assertTrue(not self.i.dirty['artist']) + self.i.load() + self.assertTrue('artist' not in self.i._dirty) class StoreTest(unittest.TestCase): def setUp(self): @@ -74,7 +74,7 @@ class StoreTest(unittest.TestCase): def test_store_changes_database_value(self): self.i.year = 1987 - self.lib.store(self.i) + self.i.store() new_year = self.lib._connection().execute( 'select year from items where ' 'title="Boracay"').fetchone()['year'] @@ -82,8 +82,8 @@ class StoreTest(unittest.TestCase): def test_store_only_writes_dirty_fields(self): original_genre = self.i.genre - self.i.record['genre'] = 'beatboxing' # change value w/o dirtying - self.lib.store(self.i) + self.i._values_fixed['genre'] = 'beatboxing' # change w/o dirtying + self.i.store() new_genre = self.lib._connection().execute( 'select genre from items where ' 'title="Boracay"').fetchone()['genre'] @@ -91,8 +91,8 @@ class StoreTest(unittest.TestCase): def test_store_clears_dirty_flags(self): self.i.composer = 'tvp' - self.lib.store(self.i) - self.assertTrue(not self.i.dirty['composer']) + self.i.store() + self.assertTrue('composer' not in self.i._dirty) class AddTest(unittest.TestCase): def setUp(self): @@ -139,11 +139,11 @@ class GetSetTest(unittest.TestCase): def test_set_sets_dirty_flag(self): self.i.comp = not self.i.comp - self.assertTrue(self.i.dirty['comp']) + self.assertTrue('comp' in self.i._dirty) def test_set_does_not_dirty_if_value_unchanged(self): self.i.title = self.i.title - self.assertTrue(not self.i.dirty['title']) + self.assertTrue('title' not in self.i._dirty) def test_invalid_field_raises_attributeerror(self): self.assertRaises(AttributeError, getattr, self.i, 'xyzzy') @@ -533,21 +533,21 @@ class DisambiguationTest(unittest.TestCase, PathFormattingMixin): def test_unique_with_default_arguments_uses_albumtype(self): album2 = self.lib.get_album(self.i1) album2.albumtype = 'bar' - self.lib._connection().commit() + album2.store() self._setf(u'foo%aunique{}/$title') self._assert_dest('/base/foo [bar]/the title', self.i1) def test_unique_expands_to_nothing_for_distinct_albums(self): album2 = self.lib.get_album(self.i2) album2.album = 'different album' - self.lib._connection().commit() + album2.store() self._assert_dest('/base/foo/the title', self.i1) def test_use_fallback_numbers_when_identical(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 - self.lib._connection().commit() + album2.store() self._assert_dest('/base/foo 1/the title', self.i1) self._assert_dest('/base/foo 2/the title', self.i2) @@ -561,6 +561,8 @@ class DisambiguationTest(unittest.TestCase, PathFormattingMixin): album2.year = 2001 album1 = self.lib.get_album(self.i1) album1.albumtype = 'foo/bar' + album2.store() + album1.store() self._setf(u'foo%aunique{albumartist album,albumtype}/$title') self._assert_dest('/base/foo [foo_bar]/the title', self.i1) @@ -757,6 +759,7 @@ class AlbumInfoTest(unittest.TestCase): def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) ai.artpath = '/my/great/art' + ai.store() new_ai = self.lib.get_album(self.i) self.assertEqual(new_ai.artpath, '/my/great/art') @@ -795,20 +798,23 @@ class AlbumInfoTest(unittest.TestCase): def test_albuminfo_changes_affect_items(self): ai = self.lib.get_album(self.i) ai.album = 'myNewAlbum' - i = self.lib.items().next() + ai.store() + i = self.lib.items()[0] self.assertEqual(i.album, 'myNewAlbum') def test_albuminfo_change_albumartist_changes_items(self): ai = self.lib.get_album(self.i) ai.albumartist = 'myNewArtist' - i = self.lib.items().next() + ai.store() + i = self.lib.items()[0] self.assertEqual(i.albumartist, 'myNewArtist') self.assertNotEqual(i.artist, 'myNewArtist') def test_albuminfo_change_artist_does_not_change_items(self): ai = self.lib.get_album(self.i) ai.artist = 'myNewArtist' - i = self.lib.items().next() + ai.store() + i = self.lib.items()[0] self.assertNotEqual(i.artist, 'myNewArtist') def test_albuminfo_remove_removes_items(self): @@ -824,15 +830,6 @@ class AlbumInfoTest(unittest.TestCase): self.lib.remove(self.i) self.assertEqual(len(self.lib.albums()), 0) -class BaseAlbumTest(_common.TestCase): - def test_field_access(self): - album = beets.library.BaseAlbum(None, {'fld1':'foo'}) - self.assertEqual(album.fld1, 'foo') - - def test_field_access_unset_values(self): - album = beets.library.BaseAlbum(None, {}) - self.assertRaises(AttributeError, getattr, album, 'field') - class ArtDestinationTest(_common.TestCase): def setUp(self): super(ArtDestinationTest, self).setUp() @@ -887,7 +884,7 @@ class PathStringTest(_common.TestCase): def test_special_chars_preserved_in_database(self): path = 'b\xe1r' self.i.path = path - self.lib.store(self.i) + self.i.store() i = list(self.lib.items())[0] self.assertEqual(i.path, path) @@ -912,9 +909,10 @@ class PathStringTest(_common.TestCase): self.assert_(isinstance(dest, str)) def test_artpath_stores_special_chars(self): - path = 'b\xe1r' + path = b'b\xe1r' alb = self.lib.add_album([self.i]) alb.artpath = path + alb.store() alb = self.lib.get_album(self.i) self.assertEqual(path, alb.artpath) diff --git a/test/test_files.py b/test/test_files.py index a9aad0f95..03588a706 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -170,7 +170,8 @@ class AlbumFileTest(_common.TestCase): def test_albuminfo_move_changes_paths(self): self.ai.album = 'newAlbumName' self.ai.move() - self.lib.load(self.i) + self.ai.store() + self.i.load() self.assert_('newAlbumName' in self.i.path) @@ -178,7 +179,8 @@ class AlbumFileTest(_common.TestCase): oldpath = self.i.path self.ai.album = 'newAlbumName' self.ai.move() - self.lib.load(self.i) + self.ai.store() + self.i.load() self.assertFalse(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) @@ -187,14 +189,16 @@ class AlbumFileTest(_common.TestCase): oldpath = self.i.path self.ai.album = 'newAlbumName' self.ai.move(True) - self.lib.load(self.i) + self.ai.store() + self.i.load() self.assertTrue(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) def test_albuminfo_move_to_custom_dir(self): self.ai.move(basedir=self.otherdir) - self.lib.load(self.i) + self.i.load() + self.ai.store() self.assertTrue('testotherdir' in self.i.path) class ArtFileTest(_common.TestCase): @@ -216,6 +220,7 @@ class ArtFileTest(_common.TestCase): self.art = self.lib.get_album(self.i).art_destination('something.jpg') touch(self.art) self.ai.artpath = self.art + self.ai.store() # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, 'testotherdir') @@ -229,7 +234,7 @@ class ArtFileTest(_common.TestCase): oldpath = self.i.path self.ai.album = 'newAlbum' self.ai.move() - self.lib.load(self.i) + self.i.load() self.assertNotEqual(self.i.path, oldpath) self.assertFalse(os.path.exists(self.art)) @@ -239,7 +244,8 @@ class ArtFileTest(_common.TestCase): def test_art_moves_with_album_to_custom_dir(self): # Move the album to another directory. self.ai.move(basedir=self.otherdir) - self.lib.load(self.i) + self.ai.store() + self.i.load() # Art should be in new directory. self.assertNotExists(self.art) @@ -344,7 +350,8 @@ class ArtFileTest(_common.TestCase): self.assertExists(oldartpath) self.ai.album = 'different_album' - self.lib.move(self.i) + self.ai.store() + self.lib.move(self.ai.items()[0]) artpath = self.lib.albums()[0].artpath self.assertTrue('different_album' in artpath) @@ -421,6 +428,7 @@ class RemoveTest(_common.TestCase): artfile = os.path.join(self.temp_dir, 'testart.jpg') touch(artfile) self.ai.set_art(artfile) + self.ai.store() parent = os.path.dirname(self.i.path) self.lib.remove(self.i, True) diff --git a/test/test_ihate.py b/test/test_ihate.py index 3ff6dcd8d..9c241fc54 100644 --- a/test/test_ihate.py +++ b/test/test_ihate.py @@ -16,7 +16,7 @@ class IHatePluginTest(unittest.TestCase): task = ImportTask() task.cur_artist = u'Test Artist' task.cur_album = u'Test Album' - task.items = [Item({'genre': 'Test Genre'})] + task.items = [Item(genre='Test Genre')] self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) genre_p = 'some_genre test\sgenre'.split() diff --git a/test/test_importer.py b/test/test_importer.py index 0b39a3fb5..34a9e3f0e 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -193,6 +193,7 @@ class ImportApplyTest(_common.TestCase): shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.srcpath) self.i = library.Item.from_path(self.srcpath) self.i.comp = False + self.lib.add(self.i) trackinfo = TrackInfo('one', 'trackid', 'some artist', 'artistid', 1) @@ -406,7 +407,7 @@ class ApplyExistingItemsTest(_common.TestCase): self._apply_asis([self.i]) # Get the item's path and import it again. - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) @@ -416,7 +417,7 @@ class ApplyExistingItemsTest(_common.TestCase): def test_apply_existing_album_does_not_duplicate_album(self): # As above. self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) @@ -425,7 +426,7 @@ class ApplyExistingItemsTest(_common.TestCase): def test_apply_existing_singleton_does_not_duplicate_album(self): self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item], False) @@ -440,7 +441,7 @@ class ApplyExistingItemsTest(_common.TestCase): self._apply_asis([self.i]) # Import again with new metadata. - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) @@ -454,12 +455,12 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['copy'] = True self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) - item = self.lib.items().next() + item = self.lib.items().get() self.assertTrue('differentTitle' in item.path) self.assertExists(item.path) @@ -468,12 +469,12 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['copy'] = False self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) - item = self.lib.items().next() + item = self.lib.items().get() self.assertFalse('differentTitle' in item.path) self.assertExists(item.path) @@ -481,13 +482,13 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['copy'] = True self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) - item = self.lib.items().next() + item = self.lib.items().get() self.assertNotExists(oldpath) def test_apply_existing_item_new_metadata_delete_enabled(self): @@ -497,13 +498,13 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['delete'] = True # ! self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) - item = self.lib.items().next() + item = self.lib.items().get() self.assertNotExists(oldpath) self.assertTrue('differentTitle' in item.path) self.assertExists(item.path) @@ -513,13 +514,13 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['copy'] = True self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) self.assertEqual(len(list(self.lib.items())), 1) - item = self.lib.items().next() + item = self.lib.items().get() self.assertEqual(oldpath, item.path) self.assertExists(oldpath) @@ -528,19 +529,19 @@ class ApplyExistingItemsTest(_common.TestCase): config['import']['delete'] = True # ! self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) self.assertEqual(len(list(self.lib.items())), 1) - item = self.lib.items().next() + item = self.lib.items().get() self.assertExists(item.path) def test_same_album_does_not_duplicate(self): # With the -L flag, exactly the same item (with the same ID) # is re-imported. This test simulates that situation. self._apply_asis([self.i]) - item = self.lib.items().next() + item = self.lib.items().get() self._apply_asis([item]) # Should not be duplicated. @@ -704,6 +705,7 @@ class DuplicateCheckTest(_common.TestCase): def test_duplicate_va_album(self): self.album.albumartist = 'an album artist' + self.album.store() res = importer._duplicate_check(self.lib, self._album_task(False, 'an album artist')) self.assertTrue(res) diff --git a/test/test_query.py b/test/test_query.py index be2e88d93..996e341b4 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -78,30 +78,30 @@ class AnyFieldQueryTest(unittest.TestCase): def test_no_restriction(self): q = beets.library.AnyFieldQuery('title', beets.library.ITEM_KEYS, beets.library.SubstringQuery) - self.assertEqual(self.lib.items(q).next().title, 'the title') + self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_completeness(self): q = beets.library.AnyFieldQuery('title', ['title'], beets.library.SubstringQuery) - self.assertEqual(self.lib.items(q).next().title, 'the title') + self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_soundness(self): q = beets.library.AnyFieldQuery('title', ['artist'], beets.library.SubstringQuery) - self.assertRaises(StopIteration, self.lib.items(q).next) + self.assertEqual(self.lib.items(q).get(), None) # Convenient asserts for matching items. class AssertsMixin(object): - def assert_matched(self, result_iterator, title): - self.assertEqual(result_iterator.next().title, title) - def assert_done(self, result_iterator): - self.assertRaises(StopIteration, result_iterator.next) - def assert_matched_all(self, result_iterator): - self.assert_matched(result_iterator, 'Littlest Things') - self.assert_matched(result_iterator, 'Take Pills') - self.assert_matched(result_iterator, 'Lovers Who Uncover') - self.assert_matched(result_iterator, 'Boracay') - self.assert_done(result_iterator) + def assert_matched(self, results, titles): + self.assertEqual([i.title for i in results], titles) + + def assert_matched_all(self, results): + self.assert_matched(results, [ + 'Littlest Things', + 'Take Pills', + 'Lovers Who Uncover', + 'Boracay', + ]) class GetTest(unittest.TestCase, AssertsMixin): def setUp(self): @@ -122,127 +122,126 @@ class GetTest(unittest.TestCase, AssertsMixin): def test_get_one_keyed_term(self): q = 'artist:Lil' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_done(results) + self.assert_matched(results, ['Littlest Things']) def test_get_one_keyed_regexp(self): q = r'artist::L.+y' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_done(results) + self.assert_matched(results, ['Littlest Things']) def test_get_one_unkeyed_term(self): q = 'Terry' results = self.lib.items(q) - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, ['Boracay']) def test_get_one_unkeyed_regexp(self): q = r':y$' results = self.lib.items(q) - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, ['Boracay']) def test_get_no_matches(self): q = 'popebear' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) def test_invalid_key(self): q = 'pope:bear' results = self.lib.items(q) - self.assert_matched_all(results) + # Matches nothing since the flexattr is not present on the + # objects. + self.assert_matched(results, []) def test_term_case_insensitive(self): q = 'UNCoVER' results = self.lib.items(q) - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, ['Lovers Who Uncover']) def test_regexp_case_sensitive(self): q = r':UNCoVER' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) q = r':Uncover' results = self.lib.items(q) - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, ['Lovers Who Uncover']) def test_term_case_insensitive_with_key(self): q = 'album:stiLL' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_done(results) + self.assert_matched(results, ['Littlest Things']) def test_key_case_insensitive(self): q = 'ArTiST:Allen' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_done(results) + self.assert_matched(results, ['Littlest Things']) def test_unkeyed_term_matches_multiple_columns(self): q = 'little' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, [ + 'Littlest Things', + 'Lovers Who Uncover', + 'Boracay', + ]) def test_unkeyed_regexp_matches_multiple_columns(self): q = r':^T' results = self.lib.items(q) - self.assert_matched(results, 'Take Pills') - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, [ + 'Take Pills', + 'Lovers Who Uncover', + 'Boracay', + ]) def test_keyed_term_matches_only_one_column(self): q = 'artist:little' results = self.lib.items(q) - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, [ + 'Lovers Who Uncover', + 'Boracay', + ]) def test_keyed_regexp_matches_only_one_column(self): q = r'album::\sS' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, [ + 'Littlest Things', + 'Lovers Who Uncover', + ]) def test_multiple_terms_narrow_search(self): q = 'little ones' results = self.lib.items(q) - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_matched(results, 'Boracay') - self.assert_done(results) + self.assert_matched(results, [ + 'Lovers Who Uncover', + 'Boracay', + ]) def test_multiple_regexps_narrow_search(self): q = r':\sS :^T' results = self.lib.items(q) - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, ['Lovers Who Uncover']) def test_mixed_terms_regexps_narrow_search(self): q = r':\sS lily' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_done(results) + self.assert_matched(results, ['Littlest Things']) def test_single_year(self): q = 'year:2006' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, [ + 'Littlest Things', + 'Lovers Who Uncover', + ]) def test_year_range(self): q = 'year:2000..2010' results = self.lib.items(q) - self.assert_matched(results, 'Littlest Things') - self.assert_matched(results, 'Take Pills') - self.assert_matched(results, 'Lovers Who Uncover') - self.assert_done(results) + self.assert_matched(results, [ + 'Littlest Things', + 'Take Pills', + 'Lovers Who Uncover', + ]) def test_bad_year(self): q = 'year:delete from items' @@ -263,55 +262,48 @@ class MemoryGetTest(unittest.TestCase, AssertsMixin): def test_singleton_true(self): q = 'singleton:true' results = self.lib.items(q) - self.assert_matched(results, 'singleton item') - self.assert_done(results) + self.assert_matched(results, ['singleton item']) def test_singleton_false(self): q = 'singleton:false' results = self.lib.items(q) - self.assert_matched(results, 'album item') - self.assert_done(results) + self.assert_matched(results, ['album item']) def test_compilation_true(self): q = 'comp:true' results = self.lib.items(q) - self.assert_matched(results, 'album item') - self.assert_done(results) + self.assert_matched(results, ['album item']) def test_compilation_false(self): q = 'comp:false' results = self.lib.items(q) - self.assert_matched(results, 'singleton item') - self.assert_done(results) + self.assert_matched(results, ['singleton item']) - def test_unknown_field_name_ignored(self): + def test_unknown_field_name_no_results(self): q = 'xyzzy:nonsense' results = self.lib.items(q) titles = [i.title for i in results] - self.assertTrue('singleton item' in titles) - self.assertTrue('album item' in titles) - self.assertEqual(len(titles), 2) + self.assertEqual(titles, []) - def test_unknown_field_name_ignored_in_album_query(self): + def test_unknown_field_name_no_results_in_album_query(self): q = 'xyzzy:nonsense' results = self.lib.albums(q) names = [a.album for a in results] - self.assertEqual(names, ['the album']) + self.assertEqual(names, []) - def test_item_field_name_ignored_in_album_query(self): + def test_item_field_name_matches_nothing_in_album_query(self): q = 'format:nonsense' results = self.lib.albums(q) names = [a.album for a in results] - self.assertEqual(names, ['the album']) + self.assertEqual(names, []) def test_unicode_query(self): self.single_item.title = u'caf\xe9' - self.lib.store(self.single_item) + self.single_item.store() q = u'title:caf\xe9' results = self.lib.items(q) - self.assert_matched(results, u'caf\xe9') - self.assert_done(results) + self.assert_matched(results, [u'caf\xe9']) class MatchTest(unittest.TestCase): def setUp(self): @@ -369,58 +361,52 @@ class PathQueryTest(unittest.TestCase, AssertsMixin): def test_path_exact_match(self): q = 'path:/a/b/c.mp3' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) def test_parent_directory_no_slash(self): q = 'path:/a' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) def test_parent_directory_with_slash(self): q = 'path:/a/' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) def test_no_match(self): q = 'path:/xyzzy/' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) def test_fragment_no_match(self): q = 'path:/b/' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) def test_nonnorm_path(self): q = 'path:/x/../a/b' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) def test_slashed_query_matches_path(self): q = '/a/b' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) def test_non_slashed_does_not_match_path(self): q = 'c.mp3' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) def test_slashes_in_explicit_field_does_not_match_path(self): q = 'title:/a/b' results = self.lib.items(q) - self.assert_done(results) + self.assert_matched(results, []) def test_path_regex(self): q = 'path::\\.mp3$' results = self.lib.items(q) - self.assert_matched(results, 'path item') - self.assert_done(results) + self.assert_matched(results, ['path item']) class BrowseTest(unittest.TestCase, AssertsMixin): def setUp(self): @@ -438,11 +424,12 @@ class BrowseTest(unittest.TestCase, AssertsMixin): def test_item_list(self): items = self.lib.items() - self.assert_matched(items, 'Littlest Things') - self.assert_matched(items, 'Take Pills') - self.assert_matched(items, 'Lovers Who Uncover') - self.assert_matched(items, 'Boracay') - self.assert_done(items) + self.assert_matched(items, [ + 'Littlest Things', + 'Take Pills', + 'Lovers Who Uncover', + 'Boracay', + ]) def test_albums_matches_album(self): albums = list(self.lib.albums('person')) @@ -454,31 +441,11 @@ class BrowseTest(unittest.TestCase, AssertsMixin): def test_items_matches_title(self): items = self.lib.items('boracay') - self.assert_matched(items, 'Boracay') - self.assert_done(items) + self.assert_matched(items, ['Boracay']) def test_items_does_not_match_year(self): items = self.lib.items('2007') - self.assert_done(items) - -class CountTest(unittest.TestCase): - def setUp(self): - self.lib = beets.library.Library(':memory:') - self.item = some_item - self.lib.add(self.item) - - def test_count_gets_single_item(self): - with self.lib.transaction() as tx: - songs, totaltime = beets.library.TrueQuery().count(tx) - self.assertEqual(songs, 1) - self.assertEqual(totaltime, self.item.length) - - def test_count_works_for_empty_library(self): - self.lib.remove(self.item) - with self.lib.transaction() as tx: - songs, totaltime = beets.library.TrueQuery().count(tx) - self.assertEqual(songs, 0) - self.assertEqual(totaltime, 0.0) + self.assert_matched(items, []) class StringParseTest(unittest.TestCase): def test_single_field_query(self): diff --git a/test/test_ui.py b/test/test_ui.py index 6cb09dcf1..868c4b6b3 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -17,7 +17,6 @@ import os import shutil import textwrap -import logging import re import yaml @@ -55,7 +54,7 @@ class ListTest(_common.TestCase): def test_list_unicode_query(self): self.item.title = u'na\xefve' - self.lib.store(self.item) + self.item.store() self.lib._connection().commit() self._run_list([u'na\xefve']) @@ -162,7 +161,7 @@ class ModifyTest(_common.TestCase): def test_modify_item_dbdata(self): self._modify(["title=newTitle"]) - item = self.lib.items().next() + item = self.lib.items().get() self.assertEqual(item.title, 'newTitle') def test_modify_album_dbdata(self): @@ -172,47 +171,47 @@ class ModifyTest(_common.TestCase): def test_modify_item_tag_unmodified(self): self._modify(["title=newTitle"], write=False) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertEqual(item.title, 'full') def test_modify_album_tag_unmodified(self): self._modify(["album=newAlbum"], write=False, album=True) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertEqual(item.album, 'the album') def test_modify_item_tag(self): self._modify(["title=newTitle"], write=True) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertEqual(item.title, 'newTitle') def test_modify_album_tag(self): self._modify(["album=newAlbum"], write=True, album=True) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertEqual(item.album, 'newAlbum') def test_item_move(self): self._modify(["title=newTitle"], move=True) - item = self.lib.items().next() + item = self.lib.items().get() self.assertTrue('newTitle' in item.path) def test_album_move(self): self._modify(["album=newAlbum"], move=True, album=True) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertTrue('newAlbum' in item.path) def test_item_not_move(self): self._modify(["title=newTitle"], move=False) - item = self.lib.items().next() + item = self.lib.items().get() self.assertFalse('newTitle' in item.path) def test_album_not_move(self): self._modify(["album=newAlbum"], move=False, album=True) - item = self.lib.items().next() + item = self.lib.items().get() item.read() self.assertFalse('newAlbum' in item.path) @@ -242,42 +241,42 @@ class MoveTest(_common.TestCase): def test_move_item(self): self._move() - self.lib.load(self.i) + self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_item(self): self._move(copy=True) - self.lib.load(self.i) + self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_album(self): self._move(album=True) - self.lib.load(self.i) + self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_album(self): self._move(copy=True, album=True) - self.lib.load(self.i) + self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_item_custom_dir(self): self._move(dest=self.otherdir) - self.lib.load(self.i) + self.i.load() self.assertTrue('testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_move_album_custom_dir(self): self._move(dest=self.otherdir, album=True) - self.lib.load(self.i) + self.i.load() self.assertTrue('testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) @@ -300,13 +299,14 @@ class UpdateTest(_common.TestCase): artfile = os.path.join(_common.RSRC, 'testart.jpg') _common.touch(artfile) self.album.set_art(artfile) + self.album.store() os.remove(artfile) def _update(self, query=(), album=False, move=False, reset_mtime=True): self.io.addinput('y') if reset_mtime: self.i.mtime = 0 - self.lib.store(self.i) + self.i.store() commands.update_items(self.lib, query, album, move, False) def test_delete_removes_item(self): @@ -333,7 +333,7 @@ class UpdateTest(_common.TestCase): mf.title = 'differentTitle' mf.save() self._update() - item = self.lib.items().next() + item = self.lib.items().get() self.assertEqual(item.title, 'differentTitle') def test_modified_metadata_moved(self): @@ -341,7 +341,7 @@ class UpdateTest(_common.TestCase): mf.title = 'differentTitle' mf.save() self._update(move=True) - item = self.lib.items().next() + item = self.lib.items().get() self.assertTrue('differentTitle' in item.path) def test_modified_metadata_not_moved(self): @@ -349,7 +349,7 @@ class UpdateTest(_common.TestCase): mf.title = 'differentTitle' mf.save() self._update(move=False) - item = self.lib.items().next() + item = self.lib.items().get() self.assertTrue('differentTitle' not in item.path) def test_modified_album_metadata_moved(self): @@ -357,7 +357,7 @@ class UpdateTest(_common.TestCase): mf.album = 'differentAlbum' mf.save() self._update(move=True) - item = self.lib.items().next() + item = self.lib.items().get() self.assertTrue('differentAlbum' in item.path) def test_modified_album_metadata_art_moved(self): @@ -376,10 +376,10 @@ class UpdateTest(_common.TestCase): # Make in-memory mtime match on-disk mtime. self.i.mtime = os.path.getmtime(self.i.path) - self.lib.store(self.i) + self.i.store() self._update(reset_mtime=False) - item = self.lib.items().next() + item = self.lib.items().get() self.assertEqual(item.title, 'full') class PrintTest(_common.TestCase): diff --git a/test/test_zero.py b/test/test_zero.py index 2cc9bcf2e..333ca2b51 100644 --- a/test/test_zero.py +++ b/test/test_zero.py @@ -7,11 +7,12 @@ from beetsplug.zero import ZeroPlugin class ZeroPluginTest(unittest.TestCase): def test_no_patterns(self): - v = {'comments' : 'test comment', - 'day' : 13, - 'month' : 3, - 'year' : 2012} - i=Item(v) + i = Item( + comments='test comment', + day=13, + month=3, + year=2012, + ) z = ZeroPlugin() z.debug = False z.fields = ['comments', 'month', 'day'] @@ -25,9 +26,10 @@ class ZeroPluginTest(unittest.TestCase): self.assertEqual(i.year, 2012) def test_patterns(self): - v = {'comments' : 'from lame collection, ripped by eac', - 'year' : 2012} - i=Item(v) + i = Item( + comments='from lame collection, ripped by eac', + year=2012, + ) z = ZeroPlugin() z.debug = False z.fields = ['comments', 'year']