Updated beets library to 1.0b11

This commit is contained in:
Remy Varma
2011-11-03 22:03:03 +00:00
parent 0ba1e578ab
commit 5cf995b3dc
27 changed files with 8689 additions and 1059 deletions
+172 -115
View File
@@ -15,14 +15,13 @@
import sqlite3
import os
import re
import shutil
import sys
from string import Template
import logging
from lib.beets.mediafile import MediaFile
from lib.beets import plugins
from lib.beets import util
from lib.beets.util import bytestring_path, syspath, normpath
from lib.beets.util import bytestring_path, syspath, normpath, samefile
MAX_FILENAME_LENGTH = 200
@@ -62,6 +61,7 @@ ITEM_FIELDS = [
('mb_artistid', 'text', True, True),
('mb_albumartistid', 'text', True, True),
('albumtype', 'text', True, True),
('label', 'text', True, True),
('length', 'real', False, True),
('bitrate', 'int', False, True),
@@ -90,6 +90,7 @@ ALBUM_FIELDS = [
('mb_albumid', 'text', True),
('mb_albumartistid', 'text', True),
('albumtype', 'text', True),
('label', 'text', True),
]
ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS]
ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]]
@@ -204,46 +205,20 @@ class Item(object):
"""
f = MediaFile(syspath(self.path))
for key in ITEM_KEYS_WRITABLE:
if getattr(self, key): #make sure it has a value before we set it and create blank tags with wrong types
setattr(f, key, getattr(self, key))
setattr(f, key, getattr(self, key))
f.save()
# Dealing with files themselves.
def move(self, library, copy=False, in_album=False):
"""Move the item to its designated location within the library
directory (provided by destination()). Subdirectories are
created as needed. If the operation succeeds, the item's path
field is updated to reflect the new location.
If copy is True, moving the file is copied rather than moved.
If in_album is True, then the track is treated as part of an
album even if it does not yet have an album_id associated with
it. (This allows items to be moved before they are added to the
database, a performance optimization.)
Passes on appropriate exceptions if directories cannot be created
or moving/copying fails.
Note that one should almost certainly call store() and
library.save() after this method in order to keep on-disk data
consistent.
# Files themselves.
def move(self, dest, copy=False):
"""Moves or copies the item's file, updating the path value if
the move succeeds.
"""
dest = library.destination(self, in_album=in_album)
# Create necessary ancestry for the move.
util.mkdirall(dest)
if not shutil._samefile(syspath(self.path), syspath(dest)):
if copy:
# copyfile rather than copy will not copy permissions
# bits, thus possibly making the copy writable even when
# the original is read-only.
shutil.copyfile(syspath(self.path), syspath(dest))
else:
shutil.move(syspath(self.path), syspath(dest))
if copy:
util.copy(self.path, dest)
else:
util.move(self.path, dest)
# Either copying or moving succeeded, so update the stored path.
self.path = dest
@@ -380,56 +355,61 @@ class CollectionQuery(Query):
clause = (' ' + joiner + ' ').join(clause_parts)
return clause, subvals
# regular expression for _parse_query, below
_pq_regex = re.compile(r'(?:^|(?<=\s))' # zero-width match for whitespace
# or beginning of string
# non-grouping optional segment for the keyword
# regular expression for _parse_query_part, below
_pq_regex = re.compile(# non-grouping optional segment for the keyword
r'(?:'
r'(\S+?)' # the keyword
r'(?<!\\):' # unescaped :
r')?'
r'(\S+)', # the term itself
r'(.+)', # the term itself
re.I) # case-insensitive
@classmethod
def _parse_query(cls, query_string):
"""Takes a query in the form of a whitespace-separated list of
search terms that may be preceded with a key followed by a
colon. Returns a list of pairs (key, term) where key is None if
the search term has no key.
def _parse_query_part(cls, part):
"""Takes a query in the form of a key/value pair separated by a
colon. Returns pair (key, term) where key is None if the search
term has no key.
For instance,
parse_query('stapler color:red') ==
[(None, 'stapler'), ('color', 'red')]
parse_query('stapler') == (None, 'stapler')
parse_query('color:red') == ('color', 'red')
Colons may be 'escaped' with a backslash to disable the keying
behavior.
"""
out = []
for match in cls._pq_regex.finditer(query_string):
out.append((match.group(1), match.group(2).replace(r'\:',':')))
return out
part = part.strip()
match = cls._pq_regex.match(part)
if match:
return match.group(1), match.group(2).replace(r'\:', ':')
@classmethod
def from_string(cls, query_string, default_fields=None, all_keys=ITEM_KEYS):
"""Creates a query from a string in the format used by
_parse_query. If default_fields are specified, they are the
def from_strings(cls, query_parts, default_fields=None, all_keys=ITEM_KEYS):
"""Creates a query from a list of strings in the format used by
_parse_query_part. If default_fields are specified, they are the
fields to be searched by unqualified search terms. Otherwise,
all fields are searched for those terms.
"""
subqueries = []
for key, pattern in cls._parse_query(query_string):
if key is None: # no key specified; match any field
subqueries.append(AnySubstringQuery(pattern, default_fields))
for part in query_parts:
res = cls._parse_query_part(part)
if not res:
continue
key, pattern = res
if key is None: # No key specified.
if os.sep in pattern:
# This looks like a path.
subqueries.append(PathQuery(pattern))
else:
# Match any field.
subqueries.append(AnySubstringQuery(pattern,
default_fields))
elif key.lower() == 'comp': # a boolean field
subqueries.append(BooleanQuery(key.lower(), pattern))
elif key.lower() == 'path':
subqueries.append(PathQuery(pattern))
elif key.lower() in all_keys: # ignore unrecognized keys
subqueries.append(SubstringQuery(key.lower(), pattern))
elif key.lower() == 'singleton':
subqueries.append(SingletonQuery(util.str2bool(pattern)))
elif key.lower() == 'path':
subqueries.append(PathQuery(pattern))
if not subqueries: # no terms in query
subqueries = [TrueQuery()]
return cls(subqueries)
@@ -491,8 +471,10 @@ class TrueQuery(Query):
class PathQuery(Query):
"""A query that matches all items under a given path."""
def __init__(self, path):
self.file_path = normpath(path) # As a file.
self.dir_path = os.path.join(path, '') # As a directory (prefix).
# Match the path as a single file.
self.file_path = normpath(path)
# As a directory (prefix).
self.dir_path = os.path.join(self.file_path, '')
def match(self, item):
return (item.path == self.file_path) or \
@@ -500,7 +482,8 @@ class PathQuery(Query):
def clause(self):
dir_pat = self.dir_path + '%'
return '(path = ?) || (path LIKE ?)', (self.file_path, dir_pat)
file_blob = buffer(bytestring_path(self.file_path))
return '(path = ?) || (path LIKE ?)', (file_blob, dir_pat)
class ResultIterator(object):
"""An iterator into an item query result set."""
@@ -540,9 +523,10 @@ class BaseLibrary(object):
@classmethod
def _get_query(cls, val=None, album=False):
"""Takes a value which may be None, a query string, or a Query
object, and returns a suitable Query object. album determines
whether the query is to match items or albums.
"""Takes a value which may be None, a query string, a query
string list, or a Query object, and returns a suitable Query
object. album determines whether the query is to match items
or albums.
"""
if album:
default_fields = ALBUM_DEFAULT_FIELDS
@@ -551,10 +535,15 @@ class BaseLibrary(object):
default_fields = ITEM_DEFAULT_FIELDS
all_keys = ITEM_KEYS
# Convert a single string into a list of space-separated
# criteria.
if isinstance(val, basestring):
val = val.split()
if val is None:
return TrueQuery()
elif isinstance(val, basestring):
return AndQuery.from_string(val, default_fields, all_keys)
elif isinstance(val, list) or isinstance(val, tuple):
return AndQuery.from_strings(val, default_fields, all_keys)
elif isinstance(val, Query):
return val
elif not isinstance(val, Query):
@@ -711,8 +700,11 @@ class Library(BaseLibrary):
art_filename='cover',
item_fields=ITEM_FIELDS,
album_fields=ALBUM_FIELDS):
self.path = bytestring_path(path)
self.directory = bytestring_path(directory)
if path == ':memory:':
self.path = path
else:
self.path = bytestring_path(normpath(path))
self.directory = bytestring_path(normpath(directory))
if path_formats is None:
path_formats = {'default': '$artist/$album/$track $title'}
elif isinstance(path_formats, basestring):
@@ -770,13 +762,15 @@ class Library(BaseLibrary):
self.conn.executescript(setup_sql)
self.conn.commit()
def destination(self, item, pathmod=None, in_album=False, fragment=False):
def destination(self, item, pathmod=None, in_album=False,
fragment=False, basedir=None):
"""Returns the path in the library directory designated for item
item (i.e., where the file ought to be). in_album forces the
item to be treated as part of an album. fragment makes this
method return just the path fragment underneath the root library
directory; the path is also returned as Unicode instead of
encoded as a bytestring.
encoded as a bytestring. basedir can override the library's base
directory for the destination.
"""
pathmod = pathmod or os.path
@@ -836,16 +830,17 @@ class Library(BaseLibrary):
if fragment:
return subpath
else:
return normpath(os.path.join(self.directory, subpath))
basedir = basedir or self.directory
return normpath(os.path.join(basedir, subpath))
# Main interface.
# Item manipulation.
def add(self, item, copy=False):
#FIXME make a deep copy of the item?
item.library = self
if copy:
item.move(self, copy=True)
self.move(item, copy=True)
# build essential parts of query
columns = ','.join([key for key in ITEM_KEYS if key != 'id'])
@@ -937,6 +932,53 @@ class Library(BaseLibrary):
if delete:
util.soft_remove(item.path)
util.prune_dirs(os.path.dirname(item.path), self.directory)
def move(self, item, copy=False, in_album=False, basedir=None,
with_album=True):
"""Move the item to its designated location within the library
directory (provided by destination()). Subdirectories are
created as needed. If the operation succeeds, the item's path
field is updated to reflect the new location.
If copy is True, moving the file is copied rather than moved.
If in_album is True, then the track is treated as part of an
album even if it does not yet have an album_id associated with
it. (This allows items to be moved before they are added to the
database, a performance optimization.)
basedir overrides the library base directory for the
destination.
If the item is in an album, the album is given an opportunity to
move its art. (This can be disabled by passing
with_album=False.)
The item is stored to the database if it is in the database, so
any dirty fields prior to the move() call will be written as a
side effect. You probably want to call save() to commit the DB
transaction.
"""
dest = self.destination(item, in_album=in_album, basedir=basedir)
# Create necessary ancestry for the move.
util.mkdirall(dest)
# Perform the move and store the change.
old_path = item.path
item.move(dest, copy)
if item.id is not None:
self.store(item)
# If this item is in an album, move its art.
if with_album:
album = self.get_album(item)
if album:
album.move_art(copy)
# Prune vacated directory.
if not copy:
util.prune_dirs(os.path.dirname(old_path), self.directory)
# Querying.
@@ -1009,25 +1051,15 @@ class Library(BaseLibrary):
if record:
return Album(self, dict(record))
def add_album(self, items, infer_aa=False):
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 if they
don't yet have an ID. Returns an Album object. If the
infer_aa flag is set, then the album artist field will be
guessed from artist fields when not present.
don't yet have an ID. Returns an Album object.
"""
# Set the metadata from the first item.
#fixme: check for consensus?
item_values = dict(
(key, getattr(items[0], key)) for key in ALBUM_KEYS_ITEM)
if infer_aa:
namemap = {
'albumartist': 'artist',
'mb_albumartistid': 'mb_artistid',
}
for field, itemfield in namemap.iteritems():
if not item_values[field]:
item_values[field] = getattr(items[0], itemfield)
sql = 'INSERT INTO albums (%s) VALUES (%s)' % \
(', '.join(ALBUM_KEYS_ITEM),
@@ -1141,37 +1173,53 @@ class Album(BaseAlbum):
(self.id,)
)
def move(self, copy=False):
"""Moves (or copies) all items to their destination. Any
album art moves along with them.
def move_art(self, copy=False):
"""Move or copy any existing album art so that it remains in the
same directory as the items.
"""
old_art = self.artpath
if not old_art:
return
new_art = self.art_destination(old_art)
if new_art == old_art:
return
log.debug('moving album art %s to %s' % (old_art, new_art))
if copy:
util.copy(old_art, new_art)
else:
util.move(old_art, new_art)
self.artpath = new_art
# Prune old path when moving.
if not copy:
util.prune_dirs(os.path.dirname(old_art),
self._library.directory)
def move(self, copy=False, basedir=None):
"""Moves (or copies) all items to their destination. Any album
art moves along with them. basedir overrides the library base
directory for the destination.
"""
basedir = basedir or self._library.directory
# Move items.
items = list(self.items())
for item in items:
item.move(self._library, copy)
newdir = os.path.dirname(items[0].path)
self._library.move(item, copy, basedir=basedir, with_album=False)
# 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(syspath(old_art), syspath(new_art))
else:
shutil.move(syspath(old_art), syspath(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)
self.move_art(copy)
def item_dir(self):
"""Returns the directory containing the album's first item,
provided that such an item exists.
"""
item = self.items().next()
try:
item = self.items().next()
except StopIteration:
raise ValueError('empty album')
return os.path.dirname(item.path)
def art_destination(self, image, item_dir=None):
@@ -1196,8 +1244,17 @@ class Album(BaseAlbum):
path = bytestring_path(path)
oldart = self.artpath
artdest = self.art_destination(path)
if oldart and samefile(path, oldart):
# Art already set.
return
elif samefile(path, artdest):
# Art already in place.
self.artpath = path
return
# Normal operation.
if oldart == artdest:
util.soft_remove(oldart)
shutil.copyfile(syspath(path), syspath(artdest))
util.copy(path, artdest)
self.artpath = artdest