mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
added file moving (no copying yet), library options; a little reorganization
--HG-- extra : convert_revision : svn%3A41726ec3-264d-0410-9c23-a9f1637257cc/trunk%4019
This commit is contained in:
parent
cd3e1d72f1
commit
e8ac0d6893
1 changed files with 184 additions and 41 deletions
225
beets/library.py
225
beets/library.py
|
|
@ -20,22 +20,51 @@ metadata_fields = [
|
||||||
('lyrics', 'text'),
|
('lyrics', 'text'),
|
||||||
('comments', 'text'),
|
('comments', 'text'),
|
||||||
('bpm', 'int'),
|
('bpm', 'int'),
|
||||||
('comp', 'bool')
|
('comp', 'bool'),
|
||||||
]
|
]
|
||||||
item_fields = [
|
item_fields = [
|
||||||
('id', 'integer primary key'),
|
('id', 'integer primary key'),
|
||||||
('path', 'text')
|
('path', 'text'),
|
||||||
] + metadata_fields
|
] + metadata_fields
|
||||||
metadata_keys = map(operator.itemgetter(0), metadata_fields)
|
metadata_keys = map(operator.itemgetter(0), metadata_fields)
|
||||||
item_keys = map(operator.itemgetter(0), item_fields)
|
item_keys = map(operator.itemgetter(0), item_fields)
|
||||||
|
|
||||||
|
# Entries in the "options" table along with their default values.
|
||||||
|
library_options = {
|
||||||
|
'directory': u'~/Music',
|
||||||
|
'path_format': u'$artist/$album/$track $title.$extension',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#### exceptions ####
|
||||||
|
|
||||||
class LibraryError(Exception):
|
class LibraryError(Exception):
|
||||||
pass
|
pass
|
||||||
class InvalidFieldError(Exception):
|
class InvalidFieldError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#### utility functions ####
|
||||||
|
|
||||||
|
def _normpath(path):
|
||||||
|
"""Provide the canonical form of the path suitable for storing in the
|
||||||
|
database."""
|
||||||
|
# force absolute paths:
|
||||||
|
# os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
||||||
|
return os.path.normpath(os.path.expanduser(path))
|
||||||
|
|
||||||
|
def _log(msg):
|
||||||
|
"""Print a log message."""
|
||||||
|
print >>sys.stderr, msg
|
||||||
|
|
||||||
|
def _ancestry(path):
|
||||||
|
"""Return a list consisting of path's parent directory, its grandparent,
|
||||||
|
and so on. For instance, _ancestry('/a/b/c') == ['/', '/a', '/a/b']."""
|
||||||
|
out = []
|
||||||
|
while path != '/':
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
out.insert(0, path)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class Item(object):
|
class Item(object):
|
||||||
|
|
@ -52,26 +81,34 @@ class Item(object):
|
||||||
pass # don't use values that aren't present
|
pass # don't use values that aren't present
|
||||||
|
|
||||||
|
|
||||||
#### field accessors ####
|
#### item field accessors ####
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, key):
|
||||||
if name in item_keys:
|
"""If key is an item attribute (i.e., a column in the database),
|
||||||
return self.record[name]
|
returns the record entry for that key. Otherwise, performs an ordinary
|
||||||
# maybe fetch if it's not available
|
getattr."""
|
||||||
|
|
||||||
|
if key in item_keys:
|
||||||
|
return self.record[key]
|
||||||
else:
|
else:
|
||||||
return self.__dict__[name]
|
return object.__getattr__(self, key)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, key, value):
|
||||||
if name in item_keys:
|
"""If key is an item attribute (i.e., a column in the database), sets
|
||||||
self.record[name] = value
|
the record entry for that key to value. If the item is associated with
|
||||||
|
a library, the new value is stored in the library's database.
|
||||||
|
|
||||||
|
Otherwise, performs an ordinary setattr."""
|
||||||
|
|
||||||
|
if key in item_keys:
|
||||||
|
self.record[key] = value
|
||||||
if self.library: # we're "connected" to a library; keep it updated
|
if self.library: # we're "connected" to a library; keep it updated
|
||||||
c = self.library.conn.cursor()
|
c = self.library.conn.cursor()
|
||||||
c.execute('update items set ?=? where id=?',
|
c.execute('update items set ' + key + '=? where id=?',
|
||||||
(self.colname, obj.record[self.colname],
|
(self.record[key], self.id))
|
||||||
obj.record['id']))
|
|
||||||
c.close()
|
c.close()
|
||||||
else:
|
else:
|
||||||
self.__dict__[name] = value
|
object.__setattr__(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
#### interaction with the database ####
|
#### interaction with the database ####
|
||||||
|
|
@ -84,7 +121,7 @@ class Item(object):
|
||||||
raise LibraryError('no library to load from')
|
raise LibraryError('no library to load from')
|
||||||
|
|
||||||
if load_id is None:
|
if load_id is None:
|
||||||
load_id = self.record['id']
|
load_id = self.id
|
||||||
|
|
||||||
c = self.library.conn.cursor()
|
c = self.library.conn.cursor()
|
||||||
c.execute('select * from items where id=?', (load_id,))
|
c.execute('select * from items where id=?', (load_id,))
|
||||||
|
|
@ -99,7 +136,7 @@ class Item(object):
|
||||||
raise LibraryError('no library to store to')
|
raise LibraryError('no library to store to')
|
||||||
|
|
||||||
if store_id is None:
|
if store_id is None:
|
||||||
store_id = self.record['id']
|
store_id = self.id
|
||||||
|
|
||||||
# build assignments for query
|
# build assignments for query
|
||||||
assignments = ','.join( ['?=?'] * (len(item_fields)-1) )
|
assignments = ','.join( ['?=?'] * (len(item_fields)-1) )
|
||||||
|
|
@ -110,7 +147,7 @@ class Item(object):
|
||||||
|
|
||||||
# finish the query
|
# finish the query
|
||||||
query = 'update items set ' + assignments + ' where id=?'
|
query = 'update items set ' + assignments + ' where id=?'
|
||||||
subvars.append(self.record['id'])
|
subvars.append(self.id)
|
||||||
|
|
||||||
c = self.library.conn.cursor()
|
c = self.library.conn.cursor()
|
||||||
c.execute(query, subvars)
|
c.execute(query, subvars)
|
||||||
|
|
@ -138,11 +175,15 @@ class Item(object):
|
||||||
new_id = c.lastrowid
|
new_id = c.lastrowid
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
self.record['id'] = new_id
|
self.record['id'] = new_id # don't use self.id because the id does not
|
||||||
|
# need to be updated
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
FixMe
|
||||||
|
|
||||||
#### interaction with files ####
|
|
||||||
|
#### interaction with files' metadata ####
|
||||||
|
|
||||||
def read(self, read_path=None):
|
def read(self, read_path=None):
|
||||||
"""Read the metadata from a file. If no read_path is provided, the
|
"""Read the metadata from a file. If no read_path is provided, the
|
||||||
|
|
@ -150,12 +191,13 @@ class Item(object):
|
||||||
the metadata is read."""
|
the metadata is read."""
|
||||||
|
|
||||||
if read_path is None:
|
if read_path is None:
|
||||||
read_path = self.record['path']
|
read_path = self.path
|
||||||
f = MediaFile(read_path)
|
f = MediaFile(read_path)
|
||||||
|
|
||||||
for key in metadata_keys:
|
for key in metadata_keys:
|
||||||
self.record[key] = getattr(f, key)
|
self.record[key] = getattr(f, key)
|
||||||
self.record['path'] = read_path
|
self.record['path'] = read_path # don't use self.path because there's
|
||||||
|
# no DB row to update yet
|
||||||
|
|
||||||
if self.library:
|
if self.library:
|
||||||
self.add()
|
self.add()
|
||||||
|
|
@ -165,7 +207,7 @@ class Item(object):
|
||||||
the metadata is written to the path stored in the item."""
|
the metadata is written to the path stored in the item."""
|
||||||
|
|
||||||
if write_path is None:
|
if write_path is None:
|
||||||
write_path = self.record['path']
|
write_path = self.path
|
||||||
f = MediaFile(write_path)
|
f = MediaFile(write_path)
|
||||||
|
|
||||||
for key in metadata_keys:
|
for key in metadata_keys:
|
||||||
|
|
@ -173,6 +215,84 @@ class Item(object):
|
||||||
|
|
||||||
f.save_tags()
|
f.save_tags()
|
||||||
|
|
||||||
|
|
||||||
|
#### dealing with files themselves ####
|
||||||
|
|
||||||
|
def destination(self):
|
||||||
|
"""Returns the path within the library directory designated for this
|
||||||
|
item (i.e., where the file ought to be)."""
|
||||||
|
|
||||||
|
libpath = self.library.options['directory']
|
||||||
|
subpath_tmpl = Template(self.library.options['path_format'])
|
||||||
|
|
||||||
|
# build the mapping for substitution in the path template, beginning
|
||||||
|
# with the values from the database
|
||||||
|
mapping = {}
|
||||||
|
for key in item_keys:
|
||||||
|
value = self.record[key]
|
||||||
|
# sanitize the value for inclusion in a path:
|
||||||
|
# replace / and leading . with _
|
||||||
|
if isinstance(value, str) or isinstance(value, unicode):
|
||||||
|
value.replace(os.sep, '_')
|
||||||
|
re.sub(r'[' + os.sep + r']|^\.', '_', value)
|
||||||
|
elif key in ('track', 'maxtrack', 'disc', 'maxdisc'):
|
||||||
|
# pad with zeros
|
||||||
|
value = '%02i' % value
|
||||||
|
else:
|
||||||
|
value = str(value)
|
||||||
|
mapping[key] = value
|
||||||
|
|
||||||
|
# one additional substitution: extension
|
||||||
|
_, extension = os.path.splitext(self.path)
|
||||||
|
extension = extension[1:] # remove leading .
|
||||||
|
mapping[u'extension'] = extension
|
||||||
|
|
||||||
|
subpath = subpath_tmpl.substitute(mapping)
|
||||||
|
return _normpath(os.path.join(libpath, subpath))
|
||||||
|
|
||||||
|
def move(self, copy=False):
|
||||||
|
"""Move the item to its designated location within the library
|
||||||
|
directory. Subdirectories are created as needed. If moving fails (for
|
||||||
|
instance, because the move would cross filesystems), a copy is
|
||||||
|
attempted. If moving or copying succeeds, the path in the database is
|
||||||
|
updated to reflect the new location.
|
||||||
|
|
||||||
|
If copy is True, moving is not attempted before copying.
|
||||||
|
|
||||||
|
Passes on appropriate exceptions if directories cannot be created or
|
||||||
|
copying fails.
|
||||||
|
|
||||||
|
Note that one should almost certainly call library.save() after this
|
||||||
|
method in order to keep on-disk data consistent."""
|
||||||
|
|
||||||
|
# We could use os.renames (super-rename) here if it didn't prune the
|
||||||
|
# old pathname first. We only need the second half of its behavior.
|
||||||
|
|
||||||
|
dest = self.destination()
|
||||||
|
|
||||||
|
# Create necessary ancestry for the move.
|
||||||
|
for ancestor in _ancestry(dest):
|
||||||
|
if not os.path.isdir(ancestor):
|
||||||
|
os.mkdir(ancestor)
|
||||||
|
|
||||||
|
try: # move
|
||||||
|
if copy:
|
||||||
|
# Hacky. Skip to "except" so we don't try moving.
|
||||||
|
raise Exception('skipping move')
|
||||||
|
os.rename(self.path, dest)
|
||||||
|
except: # copy
|
||||||
|
FixMe
|
||||||
|
|
||||||
|
# Either copying or moving succeeded, so update the stored path.
|
||||||
|
self.path = dest
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Deletes the item from the filesystem. If the item is located
|
||||||
|
in the library directory, any empty parent directories are trimmed.
|
||||||
|
Also calls remove(), deleting the appropriate row from the database."""
|
||||||
|
FixMe
|
||||||
|
self.remove()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_path(cls, path, library=None):
|
def from_path(cls, path, library=None):
|
||||||
"""Creates a new item from the media file at the specified path. If a
|
"""Creates a new item from the media file at the specified path. If a
|
||||||
|
|
@ -208,7 +328,7 @@ class Query(object):
|
||||||
ItemResultIterator."""
|
ItemResultIterator."""
|
||||||
cursor = library.conn.cursor()
|
cursor = library.conn.cursor()
|
||||||
cursor.execute(*self.statement())
|
cursor.execute(*self.statement())
|
||||||
return ResultIterator(cursor)
|
return ResultIterator(cursor, library)
|
||||||
|
|
||||||
class SubstringQuery(Query):
|
class SubstringQuery(Query):
|
||||||
"""A query that matches a substring in a specific item field."""
|
"""A query that matches a substring in a specific item field."""
|
||||||
|
|
@ -335,8 +455,9 @@ class TrueQuery(Query):
|
||||||
class ResultIterator(object):
|
class ResultIterator(object):
|
||||||
"""An iterator into an item query result set."""
|
"""An iterator into an item query result set."""
|
||||||
|
|
||||||
def __init__(self, cursor):
|
def __init__(self, cursor, library):
|
||||||
self.cursor = cursor
|
self.cursor = cursor
|
||||||
|
self.library = library
|
||||||
|
|
||||||
def __iter__(self): return self
|
def __iter__(self): return self
|
||||||
|
|
||||||
|
|
@ -346,7 +467,7 @@ class ResultIterator(object):
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
self.cursor.close()
|
self.cursor.close()
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
return Item(row)
|
return Item(row, self.library)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -361,6 +482,7 @@ class Library(object):
|
||||||
self.conn = sqlite3.connect(self.path)
|
self.conn = sqlite3.connect(self.path)
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
# this way we can access our SELECT results like dictionaries
|
# this way we can access our SELECT results like dictionaries
|
||||||
|
self.options = Library._LibraryOptionsAccessor(self)
|
||||||
self.__setup()
|
self.__setup()
|
||||||
|
|
||||||
def __setup(self):
|
def __setup(self):
|
||||||
|
|
@ -384,18 +506,39 @@ class Library(object):
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
#### utility functions ####
|
#### library options ####
|
||||||
|
|
||||||
def __normpath(self, path):
|
class _LibraryOptionsAccessor(object):
|
||||||
"""Provide the canonical form of the path suitable for storing in the
|
"""Provides access to the library's configuration variables."""
|
||||||
database. In the future, options may modify the behavior of this
|
def __init__(self, library):
|
||||||
method."""
|
self.library = library
|
||||||
# force absolute paths:
|
|
||||||
# os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
def _validate_key(self, key):
|
||||||
return os.path.normpath(os.path.expanduser(path))
|
if key not in library_options:
|
||||||
def __log(self, msg):
|
raise ValueError(key + " is not a valid option name")
|
||||||
"""Print a log message."""
|
|
||||||
print >>sys.stderr, msg
|
def __getitem__(self, key):
|
||||||
|
"""Return the current value of option "key"."""
|
||||||
|
self._validate_key(key)
|
||||||
|
c = self.library.conn.cursor()
|
||||||
|
c.execute('select value from options where key=?', (key,))
|
||||||
|
result = c.fetchone()
|
||||||
|
c.close()
|
||||||
|
if result is None: # no value stored
|
||||||
|
return library_options[key] # return default value
|
||||||
|
else:
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
"""Set the value of option "key" to "value"."""
|
||||||
|
self._validate_key(key)
|
||||||
|
c = self.library.conn.cursor()
|
||||||
|
c.execute('insert or replace into options values (?,?)',
|
||||||
|
(key, value))
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
options = None
|
||||||
|
# will be set to a _LibraryOptionsAccessor when the library is initialized
|
||||||
|
|
||||||
|
|
||||||
#### main interface ####
|
#### main interface ####
|
||||||
|
|
@ -413,17 +556,17 @@ class Library(object):
|
||||||
#fixme avoid clobbering/duplicates!
|
#fixme avoid clobbering/duplicates!
|
||||||
# add _if_ it's legible (otherwise ignore but say so)
|
# add _if_ it's legible (otherwise ignore but say so)
|
||||||
try:
|
try:
|
||||||
Item.from_path(self.__normpath(path), self)
|
Item.from_path(_normpath(path), self)
|
||||||
except FileTypeError:
|
except FileTypeError:
|
||||||
self.__log(path + ' of unknown type, skipping')
|
_log(path + ' of unknown type, skipping')
|
||||||
|
|
||||||
elif not os.path.exists(path): # no file
|
elif not os.path.exists(path): # no file
|
||||||
raise IOError('file not found: ' + path)
|
raise IOError('file not found: ' + path)
|
||||||
|
|
||||||
else: # something else: special file?
|
else: # something else: special file?
|
||||||
self.__log(path + ' special file, skipping')
|
_log(path + ' special file, skipping')
|
||||||
|
|
||||||
def get(self, query):
|
def get(self, query=None):
|
||||||
"""Returns a ResultIterator to the items matching query, which may be
|
"""Returns a ResultIterator to the items matching query, which may be
|
||||||
None (match the entire library), a Query object, or a query string."""
|
None (match the entire library), a Query object, or a query string."""
|
||||||
if query is None:
|
if query is None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue