# 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 sys import logging import shlex #from unidecode import unidecode 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, samefile from lib.beets.util.functemplate import Template 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), ('label', 'text', True, True), ('length', 'real', False, True), ('bitrate', 'int', False, True), ('format', 'text', False, True), ('samplerate', 'int', False, True), ('bitdepth', 'int', False, True), ('channels', 'int', False, True), ('mtime', 'int', False, False), ] 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), ('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]] # 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') # Special path format key. PF_KEY_DEFAULT = 'default' # 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) i.mtime = i.current_mtime() # Initial mtime. 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 the value changed, mark the field as dirty. if (not (key in self.record)) or (self.record[key] != value): self.record[key] = value self.dirty[key] = True if key in ITEM_KEYS_WRITABLE: self.mtime = 0 # Reset mtime on dirty. 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 # Database's mtime should now reflect the on-disk value. if read_path == self.path: self.mtime = self.current_mtime() def write(self): """Writes the item's metadata to the associated file. """ f = MediaFile(syspath(self.path)) plugins.send('write', item=self, mf=f) for key in ITEM_KEYS_WRITABLE: setattr(f, key, getattr(self, key)) f.save() # The file has a new mtime. self.mtime = self.current_mtime() # Files themselves. def move(self, dest, copy=False): """Moves or copies the item's file, updating the path value if the move succeeds. If a file exists at ``dest``, then it is slightly modified to be unique. """ if not util.samefile(self.path, dest): dest = util.unique_path(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 def current_mtime(self): """Returns the current mtime of the file, rounded to the nearest integer. """ return int(os.path.getmtime(syspath(self.path))) # 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) 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): value = getattr(item, self.field) or '' return self.pattern.lower() in value.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): return iter(self.subqueries) def __contains__(self, item): return 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_part, below _pq_regex = re.compile(# non-grouping optional segment for the keyword r'(?:' r'(\S+?)' # the keyword r'(?