albums() browse function now returns Album objects

As part of this, the BaseLibrary class was also adapted to include a notion of
albums. This is reflected by the new BaseAlbum class, which the Album class
(formerly _AlbumInfo) completely replaces in the concrete Library. The BaseAlbum
class just fetches metadata from the underlying items.
This commit is contained in:
Adrian Sampson 2010-07-21 15:02:08 -07:00
parent 7de294ba9f
commit cc3ec0d8aa
5 changed files with 203 additions and 155 deletions

View file

@ -219,7 +219,7 @@ class Item(object):
try:
setattr(self, key, values[key])
except KeyError:
pass # don't use values that aren't present
setattr(self, key, None)
def _clear_dirty(self):
self.dirty = {}
@ -641,16 +641,21 @@ class BaseLibrary(object):
return sorted(out)
def albums(self, artist=None, query=None):
"""Returns a sorted list of (artist, album) pairs, possibly
filtered by an artist name or an arbitrary query. Unqualified
query string terms only match fields that apply at an album
"""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.
"""
out = set()
# Gather the unique album/artist names.
pairs = set()
for item in self.get(query, ALBUM_DEFAULT_FIELDS):
if artist is None or item.artist == artist:
out.add((item.artist, item.album))
return sorted(out)
pairs.add((item.artist, item.album))
pairs = list(pairs)
pairs.sort()
# Build album objects.
return [BaseAlbum(self, artist, album) for artist, album in pairs]
def items(self, artist=None, album=None, title=None, query=None):
"""Returns a sequence of the items matching the given artist,
@ -674,6 +679,51 @@ class BaseLibrary(object):
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. Implementations can add
album-level metadata or use distinct backing stores.
"""
def __init__(self, library, artist, album):
self._library = library
self._artist = artist
self._album = album
def __getattr__(self, key):
"""Get the value for an album attribute."""
if key == 'artist':
return self._artist
elif key == 'album':
return self._album
elif key in ALBUM_KEYS_ITEM:
items = self._library.items(artist=self._artist, album=self._album)
try:
item = iter(items).next()
except StopIteration:
return None
return getattr(item, 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 ALBUM_KEYS_ITEM:
items = self._library.items(artist=self._artist, album=self._album)
for item in items:
setattr(item, key, value)
self._library.store(item)
if key == 'artist':
self._artist = artist
elif key == 'album':
self._album = album
else:
object.__setattr__(self, key, value)
# Concrete DB-backed library.
@ -834,7 +884,7 @@ class Library(BaseLibrary):
# finish the query
query = 'UPDATE items SET ' + assignments + ' WHERE id=?'
subvars.append(item.id)
subvars.append(store_id)
self.conn.execute(query, subvars)
item._clear_dirty()
@ -862,11 +912,11 @@ class Library(BaseLibrary):
# "Add" the artist to the query.
query = AndQuery((query, MatchQuery('artist', artist)))
where, subvals = query.clause()
sql = "SELECT DISTINCT artist, album FROM items " + \
sql = "SELECT id FROM albums " + \
"WHERE " + where + \
" ORDER BY artist, album"
c = self.conn.execute(sql, subvals)
return [(res[0], res[1]) for res in c.fetchall()]
return [Album(self, res[0]) for res in c.fetchall()]
def items(self, artist=None, album=None, title=None, query=None):
queries = [self._get_query(query, ITEM_DEFAULT_FIELDS)]
@ -886,7 +936,7 @@ class Library(BaseLibrary):
return ResultIterator(c, self)
# Convenience accessor.
# Convenience accessors.
def get_item(self, id):
"""Fetch an Item by its ID. Returns None if no match is found.
@ -898,131 +948,9 @@ class Library(BaseLibrary):
except StopIteration:
return None
# Album-level data.
class _AlbumInfo(object):
"""Provides access to information about albums stored in a
library.
"""
def __init__(self, library, album_id):
self._library = library
self._id = album_id
def __getattr__(self, key):
"""Get the value for an album attribute."""
if key == 'id':
return self._id
elif key in ALBUM_KEYS:
sql = 'SELECT %s FROM albums WHERE id=?' % key
c = self._library.conn.execute(sql, (self._id,))
return c.fetchone()[0]
else:
raise AttributeError('no such field %s' % key)
def __setattr__(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:
sql = 'UPDATE albums SET %s=? WHERE id=?' % key
self._library.conn.execute(sql, (value, self._id))
if key in ALBUM_KEYS_ITEM:
# Make modification on items as well.
for item in self.items():
setattr(item, key, value)
self._library.store(item)
else:
object.__setattr__(self, key, value)
def items(self):
"""Returns an iterable over the items associated with this
album.
"""
c = self._library.conn.execute(
'SELECT * FROM items WHERE album_id=?',
(self._id,)
)
return ResultIterator(c, self._library)
def remove(self, delete=False):
"""Removes this album and all its associated items from the
library. If delete, then the items' files are also deleted
from disk, along with any album art.
"""
# Remove items.
for item in self.items():
self._library.remove(item, delete)
# Delete art.
if delete:
artpath = self.artpath
if artpath:
os.unlink(artpath)
# Remove album.
self._library.conn.execute(
'DELETE FROM albums WHERE id=?',
(self._id,)
)
def move(self, copy=False):
"""Moves (or copies) all items to their destination. Any
album art moves along with them.
"""
# Move items.
items = list(self.items())
for item in items:
item.move(self._library, copy)
newdir = os.path.dirname(items[0].path)
# Move art.
old_art = self.artpath
if old_art:
new_art = self.art_destination(old_art, newdir)
if new_art != old_art:
if copy:
shutil.copy(old_art, new_art)
else:
shutil.move(old_art, new_art)
self.artpath = new_art
# Store new item paths. We do this at the end to avoid
# locking the database for too long while files are copied.
for item in items:
self._library.store(item)
def art_destination(self, image, item_dir=None):
"""Returns a path to the destination for the album art image
for the album. `image` is the path of the image that will be
moved there (used for its extension).
The path construction uses the existing path of the album's
items, so the album must contain at least one item or
item_dir must be provided.
"""
if item_dir is None:
item = self.items().next()
item_dir = os.path.dirname(item.path)
_, ext = os.path.splitext(image)
dest = os.path.join(item_dir, self._library.art_filename + ext)
return dest
def set_art(self, path):
"""Sets the album's cover art to the image at the given path.
The image is copied into place, replacing any existing art.
"""
oldart = self.artpath
artdest = self.art_destination(path)
if oldart == artdest:
os.unlink(oldart)
shutil.copy(path, artdest)
self.artpath = artdest
def get_album(self, item_or_id):
"""Given an album ID or an item associated with an album,
return an _AlbumInfo object for the album.
return an Album object for the album.
"""
if isinstance(item_or_id, int):
album_id = item_or_id
@ -1030,12 +958,12 @@ class Library(BaseLibrary):
album_id = item_or_id.album_id
if album_id is None:
return None
return self._AlbumInfo(self, album_id)
return Album(self, album_id)
def add_album(self, items):
"""Create a new album in the database with metadata derived
from its items. The items are added to the database. Returns
an _AlbumInfo object.
from its items. The items are added to the database if they
don't yet have an ID. Returns an Album object.
"""
# Set the metadata from the first item.
#fixme: check for consensus?
@ -1044,11 +972,134 @@ class Library(BaseLibrary):
', '.join(['?'] * len(ALBUM_KEYS_ITEM)))
subvals = [getattr(items[0], key) for key in ALBUM_KEYS_ITEM]
c = self.conn.execute(sql, subvals)
albuminfo = self._AlbumInfo(self, c.lastrowid)
albuminfo = Album(self, c.lastrowid)
# Add the items to the library.
for item in items:
item.album_id = albuminfo.id
self.add(item)
if item.id is None:
self.add(item)
else:
self.store(item)
return albuminfo
class Album(object):
"""Provides access to information about albums stored in a
library. Reflects the library's "albums" table, including album
art.
"""
def __init__(self, library, album_id):
self._library = library
self._id = album_id
def __getattr__(self, key):
"""Get the value for an album attribute."""
if key == 'id':
return self._id
elif key in ALBUM_KEYS:
sql = 'SELECT %s FROM albums WHERE id=?' % key
c = self._library.conn.execute(sql, (self._id,))
return c.fetchone()[0]
else:
raise AttributeError('no such field %s' % key)
def __setattr__(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:
sql = 'UPDATE albums SET %s=? WHERE id=?' % key
self._library.conn.execute(sql, (value, self._id))
if key in ALBUM_KEYS_ITEM:
# Make modification on items as well.
for item in self.items():
setattr(item, key, value)
self._library.store(item)
else:
object.__setattr__(self, key, value)
def items(self):
"""Returns an iterable over the items associated with this
album.
"""
c = self._library.conn.execute(
'SELECT * FROM items WHERE album_id=?',
(self._id,)
)
return ResultIterator(c, self._library)
def remove(self, delete=False):
"""Removes this album and all its associated items from the
library. If delete, then the items' files are also deleted
from disk, along with any album art.
"""
# Remove items.
for item in self.items():
self._library.remove(item, delete)
# Delete art.
if delete:
artpath = self.artpath
if artpath:
os.unlink(artpath)
# Remove album.
self._library.conn.execute(
'DELETE FROM albums WHERE id=?',
(self._id,)
)
def move(self, copy=False):
"""Moves (or copies) all items to their destination. Any
album art moves along with them.
"""
# Move items.
items = list(self.items())
for item in items:
item.move(self._library, copy)
newdir = os.path.dirname(items[0].path)
# Move art.
old_art = self.artpath
if old_art:
new_art = self.art_destination(old_art, newdir)
if new_art != old_art:
if copy:
shutil.copy(old_art, new_art)
else:
shutil.move(old_art, new_art)
self.artpath = new_art
# Store new item paths. We do this at the end to avoid
# locking the database for too long while files are copied.
for item in items:
self._library.store(item)
def art_destination(self, image, item_dir=None):
"""Returns a path to the destination for the album art image
for the album. `image` is the path of the image that will be
moved there (used for its extension).
The path construction uses the existing path of the album's
items, so the album must contain at least one item or
item_dir must be provided.
"""
if item_dir is None:
item = self.items().next()
item_dir = os.path.dirname(item.path)
_, ext = os.path.splitext(image)
dest = os.path.join(item_dir, self._library.art_filename + ext)
return dest
def set_art(self, path):
"""Sets the album's cover art to the image at the given path.
The image is copied into place, replacing any existing art.
"""
oldart = self.artpath
artdest = self.art_destination(path)
if oldart == artdest:
os.unlink(oldart)
shutil.copy(path, artdest)
self.artpath = artdest

View file

@ -326,8 +326,8 @@ def list_items(lib, query, album):
albums instead of single items.
"""
if album:
for artist, album in lib.albums(query=query):
print_(artist + ' - ' + album)
for album in lib.albums(query=query):
print_(album.artist + ' - ' + album.album)
else:
for item in lib.items(query=query):
print_(item.artist + ' - ' + item.album + ' - ' + item.title)
@ -350,8 +350,8 @@ def remove_items(lib, query, album, delete=False):
# Get the matching items.
if album:
items = []
for artist, album in lib.albums(query=query):
items += list(lib.items(artist=artist, album=album))
for album in lib.albums(query=query):
items += album.items()
else:
items = list(lib.items(query=query))

View file

@ -826,7 +826,8 @@ class Server(BaseServer):
conn.send(u'directory: ' + seq_to_path((artist,), PATH_PH))
elif album is None: # List all albums for an artist.
for album in self.lib.albums(artist):
conn.send(u'directory: ' + seq_to_path(album, PATH_PH))
parts = (album.artist, album.album)
conn.send(u'directory: ' + seq_to_path(parts, PATH_PH))
elif track is None: # List all tracks on an album.
for item in self.lib.items(artist, album):
conn.send(*self._item_info(item))
@ -847,6 +848,7 @@ class Server(BaseServer):
# albums
if not album:
for a in self.lib.albums(artist or None):
parts = (album.artist, album.album)
conn.send(u'directory: ' + seq_to_path(a, PATH_PH))
# tracks

Binary file not shown.

View file

@ -178,12 +178,11 @@ class BrowseTest(unittest.TestCase, AssertsMixin):
def test_album_list(self):
albums = list(self.lib.albums())
self.assertEqual(albums, [
('Lily Allen', 'Alright, Still'),
('Panda Bear', 'Person Pitch'),
('The Little Ones', 'Sing Song'),
('The Little Ones', 'Terry Tales & Fallen Gates EP'),
])
album_names = [a.album for a in albums]
for aname in ['Alright, Still', 'Person Pitch', 'Sing Song',
'Terry Tales & Fallen Gates EP']:
self.assert_(aname in album_names)
self.assertEqual(len(albums), 4)
def test_item_list(self):
items = self.lib.items()
@ -203,12 +202,8 @@ class BrowseTest(unittest.TestCase, AssertsMixin):
def test_albums_matches_album(self):
albums = list(self.lib.albums(query='person'))
self.assertEqual(albums, [('Panda Bear', 'Person Pitch')])
self.assertEqual(len(albums), 1)
def test_albums_does_not_match_title(self):
albums = list(self.lib.albums(query='boracay'))
self.assertEqual(albums, [])
def test_items_matches_title(self):
items = self.lib.items(query='boracay')
self.assert_matched(items, 'Boracay')