1
# Copyright (C) 2008-2009 Adam Olsen
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2, or (at your option)
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
21
a track database. basis for playlist, collection
23
:class:`TrackSearcher`
24
fast, advanced method for searching a dictionary of tracks
27
from xl.nls import gettext as _
28
from xl import common, track, event, xdg
31
import cPickle as pickle
38
from copy import deepcopy
39
import logging, random, time, os, time
40
logger = logging.getLogger(__name__)
42
#FIXME: make these user-customizable
43
SEARCH_ITEMS = ('artist', 'albumartist', 'album', 'title')
44
SORT_FALLBACK = ('tracknumber', 'discnumber', 'album')
46
def get_sort_tuple(fields, track):
48
Returns the sort tuple for a single track
50
:param fields: the tag(s) to sort by
51
:type fields: a single string or iterable of strings
52
:param track: the track to sort
53
:type track: :class:`xl.track.Track`
56
if type(x) == type(""):
60
if not type(fields) in (list, tuple):
61
items = [lower(track.sort_param(field))]
63
items = [lower(track.sort_param(field)) for field in fields]
68
def sort_tracks(fields, tracks, reverse=False):
70
Sorts tracks by the field passed
72
:param fields: field(s) to sort by
73
:type fields: string or list of strings
75
:param tracks: tracks to sort
76
:type tracks: list of :class:`xl.track.Track`
78
:param reverse: sort in reverse?
81
tracks = [get_sort_tuple(fields, t) for t in tracks]
82
tracks.sort(reverse=reverse)
83
return [t[-1] for t in tracks]
85
class TrackHolder(object):
86
def __init__(self, track, key, **kwargs):
91
def __getitem__(self, tag):
92
return self._track[tag]
94
def __setitem__(self, tag, values):
95
self._track[tag] = values
98
class TrackDB(object):
100
Manages a track database.
102
Allows you to add, remove, retrieve, search, save and load
105
:param name: The name of this TrackDB.
107
:param location: Path to a file where this trackDB
109
:type location: string
110
:param pickle_attrs: A list of attributes to store in the
111
pickled representation of this object. All
112
attributes listed must be built-in types, with
113
one exception: If the object contains the phrase
114
'tracks' in its name it may be a list or dict
115
or :class:`xl.track.Track` objects.
116
:type pickle_attrs: list of strings
118
def __init__(self, name='', location="", pickle_attrs=[]):
123
self.location = location
126
self.pickle_attrs = pickle_attrs
127
self.pickle_attrs += ['tracks', 'name', '_key']
131
self._deleted_keys = []
133
self.load_from_location()
134
event.timeout_add(300000, self._timeout_save)
136
def _timeout_save(self):
137
self.save_to_location()
140
def set_name(self, name):
142
Sets the name of this :class:`TrackDB`
144
:param name: The new name.
152
Gets the name of this :class:`TrackDB`
159
def set_location(self, location):
160
self.location = location
164
def load_from_location(self, location=None):
166
Restores :class:`TrackDB` state from the pickled representation
167
stored at the specified location.
169
:param location: the location to load the data from
170
:type location: string
173
location = self.location
175
raise AttributeError(
176
_("You did not specify a location to load the db from"))
179
pdata = shelve.open(self.location, flag='c',
180
protocol=common.PICKLE_PROTOCOL)
181
if pdata.has_key("_dbversion"):
182
if pdata['_dbversion'] > self._dbversion:
183
raise common.VersionError, \
184
"DB was created on a newer Exaile version."
185
except common.VersionError:
188
logger.error("Failed to open music DB.")
191
for attr in self.pickle_attrs:
195
for k in (x for x in pdata.keys() \
196
if x.startswith("tracks-")):
198
tr = track.Track(_unpickles=p[0])
199
data[tr.get_loc()] = TrackHolder(tr, p[1], **p[2])
200
setattr(self, attr, data)
202
setattr(self, attr, pdata[attr])
211
def save_to_location(self, location=None):
213
Saves a pickled representation of this :class:`TrackDB` to the
216
:param location: the location to save the data to
217
:type location: string
219
logger.debug("Saving %(name)s DB to %(location)s." %
220
{'name' : self.name, 'location' : location or self.location})
222
for k, track in self.tracks.iteritems():
223
if track._track._dirty:
231
location = self.location
233
raise AttributeError(_("You did not specify a location to save the db"))
240
pdata = shelve.open(self.location, flag='c',
241
protocol=common.PICKLE_PROTOCOL)
242
if pdata.has_key("_dbversion"):
243
if pdata['_dbversion'] > self._dbversion:
244
raise ValueError, "DB was created on a newer Exaile version."
246
logger.error("Failed to open music DB for write.")
249
for attr in self.pickle_attrs:
250
# bad hack to allow saving of lists/dicts of Tracks
252
for k, track in self.tracks.iteritems():
253
if track._track._dirty or "tracks-%s"%track._key not in pdata:
254
pdata["tracks-%s"%track._key] = (
255
track._track._pickles(),
257
deepcopy(track._attrs))
259
pdata[attr] = deepcopy(getattr(self, attr))
261
pdata['_dbversion'] = self._dbversion
263
for key in self._deleted_keys:
264
if "tracks-%s"%key in pdata:
265
del pdata["tracks-%s"%key]
270
for track in self.tracks.itervalues():
271
if track._track._dirty:
277
def list_tag(self, tag, search_terms="", use_albumartist=False,
278
ignore_the=False, sort=False, sort_by=[], reverse=False):
280
lists out all the values for a particular, tag, without duplicates
282
can also optionally prefer albumartist's value over artist's, this
283
is primarily useful for the collection panel
286
if isinstance(x, basestring):
288
x = common.the_cutter(x)
289
if isinstance(y, basestring):
291
y = common.the_cutter(y)
297
for t in self.search(search_terms):
308
cmp_type = lambda x,y: cmp(x.lower(), y.lower())
309
vals = sorted(vals, cmp=cmp_type)
311
tracks = self.search(search_terms)
312
tracks = sort_tracks(sort_by, tracks, reverse)
314
while count < len(tracks):
315
if tracks[count][tag] == tracks[count-1][tag]:
318
vals = [u" / ".join(x[tag]) for x in tracks if x[tag]]
322
def get_track_by_loc(self, loc, raw=False):
324
returns the track having the given loc. if no such track exists,
328
return self.tracks[loc]._track
332
def get_tracks_by_locs(self, locs):
334
returns the track having the given loc. if no such track exists,
337
return [self.get_track_by_loc(loc) for loc in locs]
339
def get_track_attr(self, loc, attr):
340
return self.get_track_by_loc(loc)[attr]
342
def search(self, query, sort_fields=None, return_lim=-1, tracks=None, reverse=False):
344
Search the trackDB, optionally sorting by sort_field
346
:param query: the search
347
:param sort_fields: the field(s) to sort by. Use RANDOM to sort
349
:type sort_fields: A string or list of strings
350
:param return_lim: limit the number of tracks returned to a
353
searcher = TrackSearcher()
356
elif type(tracks) == list:
359
do_search[track.get_loc()] = track
361
elif type(tracks) == dict:
366
tracksres = searcher.search(query, tracks.copy())
368
for tr in tracksres.itervalues():
369
if hasattr(tr, '_track'):
371
tracks.append(tr._track)
377
if sort_fields == 'RANDOM':
378
random.shuffle(tracks)
380
tracks = sort_tracks(sort_fields, tracks, reverse)
382
tracks = tracks[:return_lim]
386
def loc_is_member(self, loc):
388
Returns True if loc is a track in this collection, False
391
# check to see if it's in one of our libraries, this speeds things
394
if hasattr(self, 'libraries'):
395
for k, v in self.libraries.iteritems():
396
if loc.startswith('file://%s' % k):
401
# check for the actual track
402
if self.get_track_by_loc(loc):
409
Returns the number of tracks stored in this database
411
count = len(self.tracks)
414
def add(self, track):
416
Adds a track to the database of tracks
418
:param track: The Track to add
419
:type track: :class:`xl.track.Track`
421
self.add_tracks([track])
424
def add_tracks(self, tracks):
426
self.tracks[tr.get_loc()] = TrackHolder(tr, self._key)
428
event.log_event("track_added", self, tr.get_loc())
431
def remove(self, track):
433
Removes a track from the database
435
:param track: the Track to remove
438
self.remove_tracks([track])
441
def remove_tracks(self, tracks):
443
self._deleted_keys.append(self.tracks[tr.get_loc()]._key)
444
del self.tracks[tr.get_loc()]
445
event.log_event("track_removed", self, tr.get_loc())
449
class TrackSearcher(object):
451
Search a TrackDB for matching tracks
453
def tokenize_query(self, search):
455
tokenizes a search query
457
search = " " + search + " "
459
search = search.replace(" OR ", " | ")
460
search = search.replace(" NOT ", " ! ")
465
while n < len(search):
470
newsearch += search[n]
472
traceback.print_exc()
473
elif in_quotes and c != "\"":
476
in_quotes = in_quotes == False # toggle
478
elif c in ["|", "!", "(", ")"]:
479
newsearch += " " + c + " "
482
if search[n+1] != " ":
483
if search[n+1] not in ["=", ">", "<"]:
492
# split the search into tokens to be parsed
493
search = " " + newsearch.lower() + " "
494
tokens = search.split(" ")
495
tokens = [t for t in tokens if t != ""]
500
while counter < len(tokens):
501
if '"' in tokens[counter]:
503
while tk.count('"') - tk.count('\\"') < 2:
505
tk += " " + tokens[counter+1]
507
except IndexError: # someone didnt match their "s
509
first = tk.index('"', 0)
513
last = tk.index('"', last+1)
516
tk = tk[:first] + tk[first+1:last] + tk[last+1:]
520
if tokens[counter].strip() is not "":
521
etokens.append(tokens[counter])
525
# reduce tokens to a search tree and optimize it
526
tokens = self.__red(tokens)
527
tokens = self.__optimize_tokens(tokens)
531
def __optimize_tokens(self, tokens):
533
optimizes token order for fast search
535
:param tokens: tokens to optimize
536
:type tokens: token list
538
# only optimizes the top level of tokens, the speed
539
# gains from optimizing recursively are usually negligible
545
# direct equality is the most reducing so put them first
546
if type(token) == str and "=" in token:
548
# then other normal keywords
549
elif type(token) == str and "=" not in token:
551
# then anything else like ! or ()
555
tokens = l1 + l2 + l3
558
def __red(self, tokens):
560
reduce tokens to a parsable format
562
:param tokens: the list of tokens to reduce
563
:type tokens: list of string
565
# base case since we use recursion
582
if end is None and num_found == 0:
589
before = tokens[:start]
590
inside = self.__red(tokens[start+1:end])
591
after = tokens[end+1:]
592
tokens = before + [["(",inside]] + after
596
start = tokens.index("!")
598
before = tokens[:start]
599
inside = tokens[start+1:end]
601
tokens = before + [["!", inside]] + after
605
start = tokens.index("|")
606
inside = [tokens[start-1], tokens[start+1]]
607
before = tokens[:start-1]
608
after = tokens[start+2:]
609
tokens = before + [["|",inside]] + after
611
# nothing special, so just return it
615
return self.__red(tokens)
617
def search(self, query, tracks, sort_order=None):
619
executes a search using the passed query and (optionally)
622
:param query: the query to search for
624
:param tracks: the dict of tracks to use
625
:type tracks: dict of :class:`xl.track.Track`
627
tokens = self.tokenize_query(query)
628
tracks = self.__do_search(tokens, tracks)
631
def __do_search(self, tokens, current_list):
633
search for tracks by using the parsed tokens
635
:param tokens: tokens to use when searching
636
:type tokens: token list
637
:param current_list: dict of tracks to search
638
:type current_list: dict of Track
641
# if there's no more tokens, everything matches!
647
# is it a special operator?
648
if type(token) == list:
654
to_remove = self.__do_search(token[1], current_list)
655
for l,track in current_list.iteritems():
656
if l not in to_remove:
659
elif subtoken == "|":
661
self.__do_search([token[1][0]], current_list))
663
self.__do_search([token[1][1]], current_list))
665
elif subtoken == "(":
666
new_list = self.__do_search(token[1], current_list)
668
logger.warning("Bad search token")
675
tag, content = token.split("==", 1)
676
#if content[0] == "\"" and content[-1] == "\"":
677
# content = content[1:-1]
678
#content = content.strip().strip('"')
679
if content == "__null__":
681
for l,tr in current_list.iteritems():
682
if content == tr[tag]:
687
if str(t).lower() == content or t == content:
694
tag, content = token.split("=", 1)
695
content = content.strip().strip('"')
696
for l,tr in current_list.iteritems():
699
if content in str(t).lower():
706
tag, content = token.split(">", 1)
707
content = content.strip().strip('"')
708
for l,tr in current_list.iteritems():
710
if float(content) < float(tr[tag]):
716
tag, content = token.split("<", 1)
717
content = content.strip().strip('"')
718
for l,tr in current_list.iteritems():
720
if float(content) > float(tr[tag]):
726
content = token.strip().strip('"')
727
for l,tr in current_list.iteritems():
728
for item in SEARCH_ITEMS:
731
if content in t.lower():
737
return self.__do_search(tokens[1:], new_list)