# This file is part of beets. # Copyright 2011, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. 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 MAX_FILENAME_LENGTH = 200 # Fields in the "items" database table; all the metadata available for # items in the library. These are used directly in SQL; they are # vulnerable to injection if accessible to the user. # Each tuple has the following values: # - The name of the field. # - The (SQLite) type of the field. # - Is the field writable? # - Does the field reflect an attribute of a MediaFile? ITEM_FIELDS = [ ('id', 'integer primary key', False, False), ('path', 'blob', False, False), ('album_id', 'int', False, False), ('title', 'text', True, True), ('artist', 'text', True, True), ('album', 'text', True, True), ('albumartist', 'text', True, True), ('genre', 'text', True, True), ('composer', 'text', True, True), ('grouping', 'text', True, True), ('year', 'int', True, True), ('month', 'int', True, True), ('day', 'int', True, True), ('track', 'int', True, True), ('tracktotal', 'int', True, True), ('disc', 'int', True, True), ('disctotal', 'int', True, True), ('lyrics', 'text', True, True), ('comments', 'text', True, True), ('bpm', 'int', True, True), ('comp', 'bool', True, True), ('mb_trackid', 'text', True, True), ('mb_albumid', 'text', True, True), ('mb_artistid', 'text', True, True), ('mb_albumartistid', 'text', True, True), ('albumtype', 'text', True, True), ('length', 'real', False, True), ('bitrate', 'int', False, True), ('format', 'text', False, True), ] ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] ITEM_KEYS = [f[0] for f in ITEM_FIELDS] # Database fields for the "albums" table. # The third entry in each tuple indicates whether the field reflects an # identically-named field in the items table. ALBUM_FIELDS = [ ('id', 'integer primary key', False), ('artpath', 'blob', False), ('albumartist', 'text', True), ('album', 'text', True), ('genre', 'text', True), ('year', 'int', True), ('month', 'int', True), ('day', 'int', True), ('tracktotal', 'int', True), ('disctotal', 'int', True), ('comp', 'bool', True), ('mb_albumid', 'text', True), ('mb_albumartistid', 'text', True), ('albumtype', 'text', True), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] # Default search fields for various granularities. ARTIST_DEFAULT_FIELDS = ('artist',) ALBUM_DEFAULT_FIELDS = ('album', 'albumartist', 'genre') ITEM_DEFAULT_FIELDS = ARTIST_DEFAULT_FIELDS + ALBUM_DEFAULT_FIELDS + \ ('title', 'comments') # Logger. log = logging.getLogger('beets') if not log.handlers: log.addHandler(logging.StreamHandler()) # Exceptions. class InvalidFieldError(Exception): pass # Library items (songs). class Item(object): def __init__(self, values): self.dirty = {} self._fill_record(values) self._clear_dirty() @classmethod def from_path(cls, path): """Creates a new item from the media file at the specified path. """ # Initiate with values that aren't read from files. i = cls({ 'album_id': None, }) i.read(path) return i def _fill_record(self, values): self.record = {} for key in ITEM_KEYS: try: setattr(self, key, values[key]) except KeyError: setattr(self, key, None) def _clear_dirty(self): self.dirty = {} for key in ITEM_KEYS: self.dirty[key] = False def __repr__(self): return 'Item(' + repr(self.record) + ')' # Item field accessors. def __getattr__(self, key): """If key is an item attribute (i.e., a column in the database), returns the record entry for that key. """ if key in ITEM_KEYS: return self.record[key] else: raise AttributeError(key + ' is not a valid item field') def __setattr__(self, key, value): """If key is an item attribute (i.e., a column in the database), sets the record entry for that key to value. Note that to change the attribute in the database or in the file's tags, one must call store() or write(). Otherwise, performs an ordinary setattr. """ # Encode unicode paths and read buffers. if key == 'path': if isinstance(value, unicode): value = bytestring_path(value) elif isinstance(value, buffer): value = str(value) if key in ITEM_KEYS: if (not (key in self.record)) or (self.record[key] != value): # don't dirty if value unchanged self.record[key] = value self.dirty[key] = True else: super(Item, self).__setattr__(key, value) # Interaction with file metadata. def read(self, read_path=None): """Read the metadata from the associated file. If read_path is specified, read metadata from that file instead. """ if read_path is None: read_path = self.path else: read_path = normpath(read_path) f = MediaFile(syspath(read_path)) for key in ITEM_KEYS_META: setattr(self, key, getattr(f, key)) self.path = read_path def write(self): """Writes the item's metadata to the associated file. """ f = MediaFile(syspath(self.path)) for key in ITEM_KEYS_WRITABLE: 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. """ 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)) # Either copying or moving succeeded, so update the stored path. self.path = dest # Library queries. class Query(object): """An abstract class representing a query into the item database. """ def clause(self): """Returns (clause, subvals) where clause is a valid sqlite WHERE clause implementing the query and subvals is a list of items to be substituted for ?s in the clause. """ raise NotImplementedError def match(self, item): """Check whether this query matches a given Item. Can be used to perform queries on arbitrary sets of Items. """ raise NotImplementedError def statement(self, columns='*'): """Returns (query, subvals) where clause is a sqlite SELECT statement to enact this query and subvals is a list of values to substitute in for ?s in the query. """ clause, subvals = self.clause() return ('SELECT ' + columns + ' FROM items WHERE ' + clause, subvals) def count(self, library): """Returns `(num, length)` where `num` is the number of items in the library matching this query and `length` is their total length in seconds. """ clause, subvals = self.clause() statement = 'SELECT COUNT(id), SUM(length) FROM items WHERE ' + clause c = library.conn.execute(statement, subvals) result = c.fetchone() c.close() return (result[0], result[1] or 0.0) def execute(self, library): """Runs the query in the specified library, returning a ResultIterator. """ c = library.conn.cursor() stmt, subs = self.statement() log.debug('Executing query: %s' % stmt) c.execute(stmt, subs) return ResultIterator(c, library) class FieldQuery(Query): """An abstract query that searches in a specific field for a pattern. """ def __init__(self, field, pattern): if field not in ITEM_KEYS: raise InvalidFieldError(field + ' is not an item key') self.field = field self.pattern = pattern class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" def clause(self): pattern = self.pattern if self.field == 'path' and isinstance(pattern, str): pattern = buffer(pattern) return self.field + " = ?", [pattern] def match(self, item): return self.pattern == getattr(item, self.field) class SubstringQuery(FieldQuery): """A query that matches a substring in a specific item field.""" def clause(self): search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%') .replace('_','\\_')) + '%' clause = self.field + " like ? escape '\\'" subvals = [search] return clause, subvals def match(self, item): return self.pattern.lower() in getattr(item, self.field).lower() class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. """ def __init__(self, field, pattern): super(BooleanQuery, self).__init__(field, pattern) if isinstance(pattern, basestring): self.pattern = util.str2bool(pattern) self.pattern = int(self.pattern) class SingletonQuery(Query): """Matches either singleton or non-singleton items.""" def __init__(self, sense): self.sense = sense def clause(self): if self.sense: return "album_id ISNULL", () else: return "NOT album_id ISNULL", () def match(self, item): return (not item.album_id) == self.sense class CollectionQuery(Query): """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ def __init__(self, subqueries = ()): self.subqueries = subqueries # is there a better way to do this? def __len__(self): return len(self.subqueries) def __getitem__(self, key): return self.subqueries[key] def __iter__(self): iter(self.subqueries) def __contains__(self, item): item in self.subqueries def clause_with_joiner(self, joiner): """Returns a clause created by joining together the clauses of all subqueries with the string joiner (padded by spaces). """ clause_parts = [] subvals = [] for subq in self.subqueries: subq_clause, subq_subvals = subq.clause() clause_parts.append('(' + subq_clause + ')') subvals += subq_subvals 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 r'(?:' r'(\S+?)' # the keyword r'(?