mirror of
https://github.com/beetbox/beets.git
synced 2026-01-01 21:42:48 +01:00
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:
parent
7de294ba9f
commit
cc3ec0d8aa
5 changed files with 203 additions and 155 deletions
329
beets/library.py
329
beets/library.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue