mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-20 16:03:59 +01:00
Updated beets library to 1.0b11
This commit is contained in:
+172
-115
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user