flexattrs work for Albums

A second base class, LibModel, maintains a reference to the Library and should
take care of database-related tasks like load and store. This is the beginning
of the end of the terrible incongruity between Item and Album objects (only
the latter had a library reference). More refactoring to come.

One large side effect: Album objects no longer automatically store
modifications. You have to call album.store(). Several places in the code
assume otherwise; they need cleaning up.

ResultIterator is now polymorphic (it takes a type parameter, which must be a
subclass of LibModel).
This commit is contained in:
Adrian Sampson 2013-08-16 18:36:30 -07:00
parent abfad7b2a9
commit 276ce14dd2
3 changed files with 125 additions and 160 deletions

View file

@ -318,12 +318,12 @@ class FlexModel(object):
def __getattr__(self, key): def __getattr__(self, key):
if key.startswith('_'): if key.startswith('_'):
return super(FlexModel, self).__getattr__(key) raise AttributeError('model has no attribute {0!r}'.format(key))
else: else:
try: try:
return self[key] return self[key]
except KeyError: except KeyError:
raise AttributeError(key) raise AttributeError('no such field {0!r}'.format(key))
def __setattr__(self, key, value): def __setattr__(self, key, value):
if key.startswith('_'): if key.startswith('_'):
@ -331,17 +331,77 @@ class FlexModel(object):
else: else:
self[key] = value self[key] = value
class Item(FlexModel): class LibModel(FlexModel):
_fields = ITEM_FIELDS """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.
"""
def __init__(self, lib=None, **values):
self._lib = lib
super(LibModel, self).__init__(**values)
def store(self, store_id=None, store_all=False):
"""Save the object's metadata into the library database. If
store_id is specified, use it instead of the current id. If
store_all is true, save the entire record instead of just the
dirty fields.
"""
if store_id is None:
store_id = self.id
# Build assignments for query.
assignments = ''
subvars = []
for key in self._fields:
if (key != 'id') and (key in self._dirty 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)
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(store_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),
(store_id, key, value),
)
self.clear_dirty()
class Item(LibModel):
_fields = ITEM_KEYS
_table = 'items'
_flex_table = 'item_attributes'
@classmethod @classmethod
def from_path(cls, path): def from_path(cls, path):
"""Creates a new item from the media file at the specified path. """Creates a new item from the media file at the specified path.
""" """
# Initiate with values that aren't read from files. # Initiate with values that aren't read from files.
i = cls({ i = cls(album_id=None)
'album_id': None,
})
i.read(path) i.read(path)
i.mtime = i.current_mtime() # Initial mtime. i.mtime = i.current_mtime() # Initial mtime.
return i return i
@ -829,9 +889,10 @@ class PathQuery(Query):
class ResultIterator(object): class ResultIterator(object):
"""An iterator into an item query result set. The iterator lazily """An iterator into an item query result set. The iterator lazily
constructs Item objects that reflect database rows. constructs LibModel objects that reflect database rows.
""" """
def __init__(self, rows, lib, query=None): def __init__(self, model_class, rows, lib, query=None):
self.model_class = model_class
self.rows = rows self.rows = rows
self.rowiter = iter(self.rows) self.rowiter = iter(self.rows)
self.lib = lib self.lib = lib
@ -844,15 +905,17 @@ class ResultIterator(object):
for row in self.rowiter: # Iterate until we get a hit. for row in self.rowiter: # Iterate until we get a hit.
with self.lib.transaction() as tx: with self.lib.transaction() as tx:
flex_rows = tx.query( flex_rows = tx.query(
'SELECT * FROM item_attributes WHERE entity_id=?', 'SELECT * FROM {0} WHERE entity_id=?'.format(
self.model_class._flex_table
),
(row['id'],) (row['id'],)
) )
values = dict(row) values = dict(row)
values.update({row['key']: row['value'] for row in flex_rows}) values.update({row['key']: row['value'] for row in flex_rows})
item = Item(**values) obj = self.model_class(self.lib, **values)
if self.query and not self.query.match(item): if self.query and not self.query.match(obj):
continue continue
return item return obj
raise StopIteration() # Reached the end of the DB rows. raise StopIteration() # Reached the end of the DB rows.
# Regular expression for parse_query_part, below. # Regular expression for parse_query_part, below.
@ -977,54 +1040,6 @@ def get_query(val, album=False):
raise ValueError('query must be None or have type Query or str') raise ValueError('query must be None or have type Query or str')
# An abstract library.
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)
# The Library: interface to the database. # The Library: interface to the database.
class Transaction(object): class Transaction(object):
@ -1328,11 +1343,11 @@ class Library(object):
flexins = 'INSERT INTO item_attributes ' \ flexins = 'INSERT INTO item_attributes ' \
' (entity_id, key, value)' \ ' (entity_id, key, value)' \
' VALUES (?, ?, ?, ?)' ' VALUES (?, ?, ?, ?)'
for key, value in item.flexattrs.items(): for key, value in item._values_flex.items():
if value is not None: if value is not None:
tx.mutate(flexins, (new_id, key, value)) tx.mutate(flexins, (new_id, key, value))
item._clear_dirty() item.clear_dirty()
item.id = new_id item.id = new_id
self._memotable = {} self._memotable = {}
return new_id return new_id
@ -1344,9 +1359,8 @@ class Library(object):
if load_id is None: if load_id is None:
load_id = item.id load_id = item.id
stored_item = self.get_item(load_id) stored_item = self.get_item(load_id)
item.update(stored_item.record) item.update(dict(stored_item))
item.update(stored_item.flexattrs) item.clear_dirty()
item._clear_dirty()
def store(self, item, store_id=None, store_all=False): def store(self, item, store_id=None, store_all=False):
"""Save the item's metadata into the library database. If """Save the item's metadata into the library database. If
@ -1361,7 +1375,7 @@ class Library(object):
assignments = '' assignments = ''
subvars = [] subvars = []
for key in ITEM_KEYS: for key in ITEM_KEYS:
if (key != 'id') and (item.dirty[key] or store_all): if (key != 'id') and (key in item._dirty or store_all):
assignments += key + '=?,' assignments += key + '=?,'
value = getattr(item, key) value = getattr(item, key)
# Wrap path strings in buffers so they get stored # Wrap path strings in buffers so they get stored
@ -1382,10 +1396,10 @@ class Library(object):
flexins = 'INSERT INTO item_attributes ' \ flexins = 'INSERT INTO item_attributes ' \
' (entity_id, key, value)' \ ' (entity_id, key, value)' \
' VALUES (?, ?, ?)' ' VALUES (?, ?, ?)'
for key, value in item.flexattrs.items(): for key, value in item._values_flex.items():
tx.mutate(flexins, (store_id, key, value)) tx.mutate(flexins, (store_id, key, value))
item._clear_dirty() item.clear_dirty()
self._memotable = {} self._memotable = {}
def remove(self, item, delete=False, with_album=True): def remove(self, item, delete=False, with_album=True):
@ -1479,18 +1493,7 @@ class Library(object):
subvals, subvals,
) )
if where: return ResultIterator(Album, rows, self, None if where else query)
# Fast query.
return [Album(self, dict(res)) for res in rows]
else:
# Slow query.
# FIXME both should be iterators.
out = []
for row in rows:
album = Album(self, dict(res))
if query.match(album):
out.append(album)
return out
def items(self, query=None, artist=None, album=None, title=None): def items(self, query=None, artist=None, album=None, title=None):
"""Returns a sequence of the items matching the given artist, """Returns a sequence of the items matching the given artist,
@ -1519,12 +1522,7 @@ class Library(object):
subvals subvals
) )
if where: return ResultIterator(Item, rows, self, None if where else query)
# Fast query.
return ResultIterator(rows, self)
else:
# Slow query.
return ResultIterator(rows, self, query)
# Convenience accessors. # Convenience accessors.
@ -1550,13 +1548,11 @@ class Library(object):
if album_id is None: if album_id is None:
return None return None
with self.transaction() as tx: albums = self.albums(MatchQuery('id', album_id))
rows = tx.query( try:
'SELECT * FROM albums WHERE id=?', return albums.next()
(album_id,) except StopIteration:
) return None
if rows:
return Album(self, dict(rows[0]))
def add_album(self, items): def add_album(self, items):
"""Create a new album in the database with metadata derived """Create a new album in the database with metadata derived
@ -1587,71 +1583,33 @@ class Library(object):
self.store(item) self.store(item)
# Construct the new Album object. # Construct the new Album object.
record = {} album_values['id'] = album_id
for key in ALBUM_KEYS: album = Album(self, **album_values)
# Unset (non-item) fields default to None.
record[key] = album_values.get(key)
record['id'] = album_id
album = Album(self, record)
return album return album
class Album(BaseAlbum): class Album(LibModel):
"""Provides access to information about albums stored in a """Provides access to information about albums stored in a
library. Reflects the library's "albums" table, including album library. Reflects the library's "albums" table, including album
art. art.
""" """
def __init__(self, lib, record): _fields = ALBUM_KEYS
# Decode Unicode paths in database. _table = 'album'
if 'artpath' in record and isinstance(record['artpath'], unicode): _flex_table = 'album_attributes'
record['artpath'] = bytestring_path(record['artpath'])
super(Album, self).__init__(lib, record)
def __setattr__(self, key, value): def __setitem__(self, key, value):
"""Set the value of an album attribute.""" """Set the value of an album attribute."""
if key == 'id': if key == 'artpath':
raise AttributeError("can't modify album id") if isinstance(value, unicode):
elif key in ALBUM_KEYS:
# Make sure paths are bytestrings.
if key == 'artpath' and isinstance(value, unicode):
value = bytestring_path(value) value = bytestring_path(value)
elif isinstance(value, buffer):
# Reflect change in this object. value = bytes(value)
self._record[key] = value super(Album, self).__setitem__(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
def items(self): def items(self):
"""Returns an iterable over the items associated with this """Returns an iterable over the items associated with this
album. album.
""" """
return self._library.items(MatchQuery('album_id', self.id)) return self._lib.items(MatchQuery('album_id', self.id))
def remove(self, delete=False, with_items=True): def remove(self, delete=False, with_items=True):
"""Removes this album and all its associated items from the """Removes this album and all its associated items from the
@ -1666,11 +1624,11 @@ class Album(BaseAlbum):
if artpath: if artpath:
util.remove(artpath) util.remove(artpath)
with self._library.transaction() as tx: with self._lib.transaction() as tx:
if with_items: if with_items:
# Remove items. # Remove items.
for item in self.items(): for item in self.items():
self._library.remove(item, delete, False) self._lib.remove(item, delete, False)
# Remove album from database. # Remove album from database.
tx.mutate( tx.mutate(
@ -1701,22 +1659,22 @@ class Album(BaseAlbum):
# Prune old path when moving. # Prune old path when moving.
if not copy: if not copy:
util.prune_dirs(os.path.dirname(old_art), util.prune_dirs(os.path.dirname(old_art),
self._library.directory) self._lib.directory)
def move(self, copy=False, basedir=None): 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 art moves along with them. basedir overrides the library base
directory for the destination. directory for the destination.
""" """
basedir = basedir or self._library.directory basedir = basedir or self._lib.directory
# Move items. # Move items.
items = list(self.items()) items = list(self.items())
for item in 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. # Move art.
self.move_art(copy) self.move_art(lib, copy)
def item_dir(self): def item_dir(self):
"""Returns the directory containing the album's first item, """Returns the directory containing the album's first item,
@ -1743,7 +1701,7 @@ class Album(BaseAlbum):
filename_tmpl = Template(beets.config['art_filename'].get(unicode)) filename_tmpl = Template(beets.config['art_filename'].get(unicode))
subpath = format_for_path(self.evaluate_template(filename_tmpl)) subpath = format_for_path(self.evaluate_template(filename_tmpl))
subpath = util.sanitize_path(subpath, subpath = util.sanitize_path(subpath,
replacements=self._library.replacements) replacements=self._lib.replacements)
subpath = bytestring_path(subpath) subpath = bytestring_path(subpath)
_, ext = os.path.splitext(image) _, ext = os.path.splitext(image)
@ -1783,8 +1741,8 @@ class Album(BaseAlbum):
""" """
# Get template field values. # Get template field values.
mapping = {} mapping = {}
for key in ALBUM_KEYS: for key, value in dict(self).items():
mapping[key] = format_for_path(getattr(self, key), key) mapping[key] = format_for_path(value, key)
mapping['artpath'] = displayable_path(mapping['artpath']) mapping['artpath'] = displayable_path(mapping['artpath'])
mapping['path'] = displayable_path(self.item_dir()) mapping['path'] = displayable_path(self.item_dir())
@ -1800,6 +1758,14 @@ class Album(BaseAlbum):
# Perform substitution. # Perform substitution.
return template.substitute(mapping, funcs) return template.substitute(mapping, funcs)
def load(self):
"""Refresh this album's cached metadata from the library.
"""
items = self._lib.items(artist=self.artist, album=self.album)
item = iter(items).next()
for key in ALBUM_KEYS_ITEM:
self[key] = item[key]
# Default path template resources. # Default path template resources.

View file

@ -1162,8 +1162,9 @@ def modify_items(lib, mods, query, write, move, album, confirm):
else: else:
lib.move(obj) lib.move(obj)
# When modifying items, we have to store them to the database. if album:
if not album: obj.store()
else:
lib.store(obj) lib.store(obj)
# Apply tags if requested. # Apply tags if requested.

View file

@ -70,12 +70,10 @@ def compile_inline(python_code, album):
is_expr = True is_expr = True
def _dict_for(obj): def _dict_for(obj):
out = dict(obj)
if album: if album:
out = dict(obj._record)
out['items'] = list(obj.items()) out['items'] = list(obj.items())
return out return out
else:
return dict(obj)
if is_expr: if is_expr:
# For expressions, just evaluate and return the result. # For expressions, just evaluate and return the result.