Fixed bug in get_albums()
[jamaendo] / jamaendo / api.py
1 #!/usr/bin/env python
2 #
3 # This file is part of Jamaendo.
4 # Copyright (c) 2010, Kristoffer Gronlund
5 # All rights reserved.
6 #
7 # Redistribution and use in source and binary forms, with or without
8 # modification, are permitted provided that the following conditions are met:
9 #     * Redistributions of source code must retain the above copyright
10 #       notice, this list of conditions and the following disclaimer.
11 #
12 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
13 # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
14 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
15 # DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
16 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
17 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
18 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
19 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
20 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
21 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
23 # An improved, structured jamendo API wrapper for the N900 with cacheing
24 # Image / cover downloads.. and more?
25 import urllib, threading, os, time, simplejson, re
26 import logging, hashlib
27 import pycurl, StringIO
28
29 _CACHEDIR = None
30 _COVERDIR = None
31 _GET2 = '''http://api.jamendo.com/get2/'''
32 _MP3URL = _GET2+'stream/track/redirect/?id=%d&streamencoding=mp31'
33 _OGGURL = _GET2+'stream/track/redirect/?id=%d&streamencoding=ogg2'
34 _TORRENTURL = _GET2+'bittorrent/file/redirect/?album_id=%d&type=archive&class=mp32'
35
36 try:
37     log = logging.getLogger(__name__)
38 except:
39     class StdoutLogger(object):
40         def info(self, s, *args):
41             print s % (args)
42         def debug(self, s, *args):
43             pass#print s % (args)
44     log = StdoutLogger()
45
46 # These classes can be partially constructed,
47 # and if asked for a property they don't know,
48 # makes a query internally to get the full story
49
50 _ARTIST_FIELDS = ['id', 'name', 'image']
51 _ALBUM_FIELDS = ['id', 'name', 'image', 'artist_name', 'artist_id', 'license_url']
52 _TRACK_FIELDS = ['id', 'name', 'album_image', 'artist_id', 'artist_name', 'album_name', 'album_id', 'numalbum', 'duration']
53 _RADIO_FIELDS = ['id', 'name', 'idstr', 'image']
54 _TAG_FIELDS = ['id', 'name']
55
56 def curlGET(url):
57     c = pycurl.Curl()
58     s = StringIO.StringIO()
59     c.setopt(pycurl.FOLLOWLOCATION, 1)
60     c.setopt(pycurl.URL, url)
61     c.setopt(pycurl.WRITEFUNCTION, s.write)
62     try:
63         c.perform()
64     finally:
65         c.close()
66     s.seek(0)
67     return s.read()
68
69 class LazyQuery(object):
70     def set_from_json(self, json):
71         for key, value in json.iteritems():
72             if key == 'id':
73                 assert(self.ID == int(value))
74             else:
75                 if key.endswith('_id'):
76                     value = int(value)
77                 setattr(self, key, value)
78
79     def load(self):
80         """Not automatic for now,
81         will have to do artist.load()
82
83         This is filled in further down
84         in the file
85         """
86         raise NotImplemented
87
88     def _needs_load(self):
89         return True
90
91     def _set_from(self, other):
92         raise NotImplemented
93
94     def _needs_load_impl(self, *attrs):
95         for attr in attrs:
96             if getattr(self, attr) is None:
97                 return True
98         return False
99
100     def _set_from_impl(self, other, *attrs):
101         for attr in attrs:
102             self._set_if(other, attr)
103
104     def _set_if(self, other, attrname):
105         if getattr(self, attrname) is None and getattr(other, attrname) is not None:
106             setattr(self, attrname, getattr(other, attrname))
107
108     def __repr__(self):
109         try:
110             return u"%s(%s)"%(self.__class__.__name__,
111                               u", ".join(("%s:%s"%(k,repr(v))) for k,v in self.__dict__.iteritems() if not k.startswith('_')))
112         except UnicodeEncodeError:
113             #import traceback
114             #traceback.print_exc()
115             return u"%s(?)"%(self.__class__.__name__)
116
117 class Artist(LazyQuery):
118     def __init__(self, ID, json=None):
119         self.ID = int(ID)
120         self.name = None
121         self.image = None
122         self.albums = None # None means not downloaded
123         if json:
124             self.set_from_json(json)
125
126     def _needs_load(self):
127         return self._needs_load_impl('name', 'albums')
128
129     def _set_from(self, other):
130         return self._set_from_impl(other, 'name', 'image', 'albums')
131
132 class Album(LazyQuery):
133     def __init__(self, ID, json=None):
134         self.ID = int(ID)
135         self.name = None
136         self.image = None
137         self.artist_name = None
138         self.artist_id = None
139         self.license_url = None
140         self.tracks = None # None means not downloaded
141         if json:
142             self.set_from_json(json)
143
144     def torrent_url(self):
145         return _TORRENTURL%(self.ID)
146
147
148     def _needs_load(self):
149         return self._needs_load_impl('name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
150
151     def _set_from(self, other):
152         return self._set_from_impl(other, 'name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
153
154 class Track(LazyQuery):
155     def __init__(self, ID, json=None):
156         self.ID = int(ID)
157         self.name = None
158         self.artist_id = None
159         self.artist_name = None
160         self.album_image = None
161         self.album_name = None
162         self.album_id = None
163         self.numalbum = None
164         self.duration = None
165         if json:
166             self.set_from_json(json)
167
168     def mp3_url(self):
169        return _MP3URL%(self.ID)
170
171     def ogg_url(self):
172        return _OGGURL%(self.ID)
173
174     def _needs_load(self):
175         return self._needs_load_impl('name', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
176
177     def _set_from(self, other):
178         return self._set_from_impl(other, 'name', 'album_image', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
179
180 class Radio(LazyQuery):
181     def __init__(self, ID, json=None):
182         self.ID = int(ID)
183         self.name = None
184         self.idstr = None
185         self.image = None
186         if json:
187             self.set_from_json(json)
188
189     def _needs_load(self):
190         return self._needs_load_impl('name', 'idstr', 'image')
191
192     def _set_from(self, other):
193         return self._set_from_impl(other, 'name', 'idstr', 'image')
194
195 class Tag(LazyQuery):
196     def __init__(self, ID, json=None):
197         self.ID = int(ID)
198         self.name = None
199         if json:
200             self.set_from_json(json)
201
202     def _needs_load(self):
203         return self._needs_load_impl('name')
204
205     def _set_from(self, other):
206         return self._set_from_impl(other, 'name')
207
208 _artists = {} # id -> Artist()
209 _albums = {} # id -> Album()
210 _tracks = {} # id -> Track()
211 _radios = {} # id -> Radio()
212
213
214 # cache sizes per session (TODO)
215 _CACHED_ARTISTS = 100
216 _CACHED_ALBUMS = 200
217 _CACHED_TRACKS = 500
218 _CACHED_RADIOS = 10
219 # cache sizes, persistant
220 _CACHED_COVERS = 2048
221
222 # TODO: cache queries?
223
224 class Query(object):
225     rate_limit = 1.1 # seconds between queries
226     last_query = time.time() - 1.5
227
228     @classmethod
229     def _ratelimit(cls):
230         now = time.time()
231         if now - cls.last_query < cls.rate_limit:
232             time.sleep(cls.rate_limit - (now - cls.last_query))
233         cls.last_query = now
234
235     def __init__(self):
236         pass
237
238     def _geturl(self, url):
239         log.info("%s", url)
240         Query._ratelimit()
241         try:
242             ret = simplejson.loads(curlGET(url))
243         except Exception, e:
244             return None
245         return ret
246
247     def __str__(self):
248         return "#%s" % (self.__class__.__name__)
249
250     def execute(self):
251         raise NotImplemented
252
253 class CoverFetcher(threading.Thread):
254     def __init__(self):
255         threading.Thread.__init__(self)
256         self.setDaemon(True)
257         self.cond = threading.Condition()
258         self.work = []
259
260     def _retrieve(self, url, fname):
261         f = open(fname, 'wb')
262         c = pycurl.Curl()
263         c.setopt(pycurl.FOLLOWLOCATION, 1)
264         c.setopt(pycurl.URL, str(url))
265         c.setopt(pycurl.WRITEFUNCTION, f.write)
266         try:
267             c.perform()
268         except:
269             fname = None
270         finally:
271             c.close()
272             f.close()
273         log.debug("Coverfetch: %s -> %s", url, fname)
274         return fname
275
276     def _fetch_cover(self, albumid, size):
277         try:
278             coverdir = _COVERDIR if _COVERDIR else '/tmp'
279             to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
280             if not os.path.isfile(to):
281                 url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
282                 to = self._retrieve(url, to)
283             return to
284         except Exception, e:
285             return None
286
287     def _fetch_image(self, url):
288         try:
289             h = hashlib.md5(url).hexdigest()
290             coverdir = _COVERDIR if _COVERDIR else '/tmp'
291             to = os.path.join(coverdir, h+'.jpg')
292             if not os.path.isfile(to):
293                 to = self._retrieve(url, to)
294             return to
295         except Exception, e:
296             return None
297
298     def request_cover(self, albumid, size, cb):
299         self.cond.acquire()
300         self.work.insert(0, (albumid, size, cb))
301         self.cond.notify()
302         self.cond.release()
303
304     def request_images(self, urls, cb):
305         """cb([(url, image)])"""
306         self.cond.acquire()
307         self.work = [('image', url, cb) for url in urls] + self.work
308         self.cond.notify()
309         self.cond.release()
310
311     def run(self):
312         while True:
313             work = []
314             self.cond.acquire()
315             while True:
316                 work = self.work
317                 if work:
318                     self.work = []
319                     break
320                 self.cond.wait()
321             self.cond.release()
322
323             multi = len(work) > 1
324             for job in work:
325                 if job[0] == 'image':
326                     self.process_image(job[1], job[2])
327                 else:
328                     self.process_cover(*job)
329
330     def process_cover(self, albumid, size, cb):
331         cover = self._fetch_cover(albumid, size)
332         if cover:
333             cb(albumid, size, cover)
334
335     def process_image(self, url, cb):
336         image = self._fetch_image(url)
337         if image:
338             cb([(url, image)])
339
340 class CoverCache(object):
341     """
342     cache and fetch covers
343     TODO: background thread that
344     fetches and returns covers,
345     asynchronously, LIFO
346     """
347     def __init__(self):
348         self._covers = {} # (albumid, size) -> file
349         self._images = {}
350         self._fetcher = CoverFetcher()
351         self._fetcher.start()
352         if _COVERDIR and os.path.isdir(_COVERDIR):
353             self.prime_cache()
354
355     def prime_cache(self):
356         coverdir = _COVERDIR
357         covermatch = re.compile(r'(\d+)\-(\d+)\.jpg')
358
359         prev_covers = os.listdir(coverdir)
360
361         if len(prev_covers) > _CACHED_COVERS:
362             import random
363             dropn = len(prev_covers) - _CACHED_COVERS
364             todrop = random.sample(prev_covers, dropn)
365             log.warning("Deleting from cache: %s", todrop)
366             for d in todrop:
367                 m = covermatch.match(d)
368                 if m:
369                     try:
370                         os.unlink(os.path.join(coverdir, d))
371                     except OSError, e:
372                         log.exception('unlinking failed')
373
374         for fil in os.listdir(coverdir):
375             fl = os.path.join(coverdir, fil)
376             m = covermatch.match(fil)
377             if m and os.path.isfile(fl):
378                 self._covers[(int(m.group(1)), int(m.group(2)))] = fl
379
380     def fetch_cover(self, albumid, size):
381         coverdir = _COVERDIR
382         if coverdir:
383             to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
384             if not os.path.isfile(to):
385                 url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
386                 urllib.urlretrieve(url, to)
387                 self._covers[(albumid, size)] = to
388             return to
389         return None
390
391     def get_cover(self, albumid, size):
392         cover = self._covers.get((albumid, size), None)
393         if not cover:
394             cover = self.fetch_cover(albumid, size)
395         return cover
396
397     def get_async(self, albumid, size, cb):
398         cover = self._covers.get((albumid, size), None)
399         if cover:
400             cb(albumid, size, cover)
401         else:
402             def cb2(albumid, size, cover):
403                 self._covers[(albumid, size)] = cover
404                 cb(albumid, size, cover)
405             self._fetcher.request_cover(albumid, size, cb2)
406
407     def get_images_async(self, url_list, cb):
408         found = []
409         lookup = []
410         for url in url_list:
411             image = self._images.get(url, None)
412             if image:
413                 found.append((url, image))
414             else:
415                 lookup.append(url)
416         if found:
417             cb(found)
418
419         if lookup:
420             def cb2(results):
421                 for url, image in results:
422                     self._images[url] = image
423                 cb(results)
424             self._fetcher.request_images(lookup, cb2)
425
426 _cover_cache = CoverCache()
427
428 def set_cache_dir(cachedir):
429     global _CACHEDIR
430     global _COVERDIR
431     _CACHEDIR = cachedir
432     _COVERDIR = os.path.join(_CACHEDIR, 'covers')
433
434     try:
435         os.makedirs(_CACHEDIR)
436     except OSError:
437         pass
438
439     try:
440         os.makedirs(_COVERDIR)
441     except OSError:
442         pass
443
444     _cover_cache.prime_cache()
445
446 def get_album_cover(albumid, size=100):
447     return _cover_cache.get_cover(albumid, size)
448
449 def get_album_cover_async(cb, albumid, size=100):
450     _cover_cache.get_async(albumid, size, cb)
451
452 def get_images_async(cb, url_list):
453     _cover_cache.get_images_async(url_list, cb)
454
455 class CustomQuery(Query):
456     def __init__(self, url):
457         Query.__init__(self)
458         self.url = url
459
460     def execute(self):
461         return self._geturl(self.url)
462
463     def __str__(self):
464         return self.url
465
466 class GetQuery(Query):
467     queries = {
468         'artist' : {
469             'url' : _GET2+'+'.join(_ARTIST_FIELDS)+'/artist/json/?',
470             'params' : 'artist_id=%d',
471             'constructor' : Artist
472             },
473         'artist_list' : {
474             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/artist/json/?',
475             'params' : 'artist_id=%s',
476             'constructor' : Album
477             },
478         'album' : {
479             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
480             'params' : 'album_id=%d',
481             'constructor' : Album
482             },
483         'album_list' : {
484             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
485             'params' : 'album_id=%s',
486             'constructor' : Album
487             },
488         'albums' : {
489             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
490             'params' : 'artist_id=%d',
491             'constructor' : [Album]
492             },
493         'track' : {
494             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
495             'params' : 'id=%d',
496             'constructor' : Track
497             },
498         'track_list' : {
499             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
500             'params' : 'id=%s',
501             'constructor' : Track
502             },
503         'tracks' : {
504             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
505             'params' : 'order=numalbum_asc&album_id=%d',
506             'constructor' : [Track]
507             },
508         'radio' : {
509             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/radio_track_inradioplaylist+track_album+album_artist/?',
510             'params' : 'order=random_asc&radio_id=%d&n=16',
511             'constructor' : [Track]
512             },
513         'favorite_albums' : {
514             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/album_user_starred/?',
515             'params' : 'user_idstr=%s',
516             'constructor' : [Album]
517             },
518         'tag' : {
519             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
520             'params' : 'tag_id=%d&n=50&order=rating_desc',
521             'constructor' : [Track]
522             },
523         }
524
525     def __init__(self, what, ID):
526         Query.__init__(self)
527         self.ID = ID
528         info = GetQuery.queries[what]
529         self.url = info['url']
530         self.params = info['params']
531         self.constructor = info['constructor']
532
533     def construct(self, data):
534         constructor = self.constructor
535         if isinstance(constructor, list):
536             constructor = constructor[0]
537         if isinstance(data, list):
538             return [constructor(int(x['id']), json=x) for x in data]
539         else:
540             return constructor(int(data['id']), json=data)
541
542     def execute(self):
543         js = self._geturl(self.url + self.params % (self.ID))
544         if not js:
545             return None
546         return self.construct(js)
547
548     def __str__(self):
549         return self.url + self.params % (self.ID)
550
551 class SearchQuery(GetQuery):
552     def __init__(self, what, query=None, order=None, user=None, count=20):
553         GetQuery.__init__(self, what, None)
554         self.query = query
555         self.order = order
556         self.count = count
557         self.user = user
558
559     def execute(self):
560         params = {}
561         if self.query:
562             params['searchquery'] = self.query
563         if self.order:
564             params['order'] = self.order
565         if self.count:
566             params['n'] = self.count
567         if self.user:
568             params['user_idstr'] = self.user
569         js = self._geturl(self.url +  urllib.urlencode(params))
570         if not js:
571             return None
572         return self.construct(js)
573
574     def __str__(self):
575         params = {'searchquery':self.query, 'order':self.order, 'n':self.count}
576         return self.url +  urllib.urlencode(params)
577
578 class JamendoAPIException(Exception):
579     def __init__(self, url):
580         Exception.__init__(self, url)
581
582 def _update_cache(cache, new_items):
583     if not isinstance(new_items, list):
584         new_items = [new_items]
585     for item in new_items:
586         old = cache.get(item.ID)
587         if old:
588             old._set_from(item)
589         else:
590             cache[item.ID] = item
591         if isinstance(item, Artist) and item.albums:
592             for album in item.albums:
593                 _update_cache(_albums, album)
594         elif isinstance(item, Album) and item.tracks:
595             for track in item.tracks:
596                 _update_cache(_tracks, track)
597     # enforce cache limits here!
598     # also, TODO: save/load cache between sessions
599     # that will require storing a timestamp with
600     # each item, though..
601     # perhaps,
602     # artists: 1 day - changes often
603     # albums: 2-5 days - changes less often (?)
604     # tracks: 1 week - changes rarely, queried often
605
606 def get_artist(artist_id):
607     """Returns: Artist"""
608     a = _artists.get(artist_id, None)
609     if not a:
610         q = GetQuery('artist', artist_id)
611         a = q.execute()
612         if not a:
613             raise JamendoAPIException(str(q))
614         _update_cache(_artists, a)
615         if isinstance(a, list):
616             a = a[0]
617     return a
618
619 def get_artists(artist_ids):
620     """Returns: [Artist]"""
621     assert(isinstance(artist_ids, list))
622     found = []
623     lookup = []
624     for artist_id in artist_ids:
625         a = _artists.get(artist_id, None)
626         if not a:
627             lookup.append(artist_id)
628         else:
629             found.append(a)
630     if lookup:
631         q = GetQuery('artist_list', '+'.join(str(x) for x in lookup))
632         a = q.execute()
633         if not a:
634             raise JamendoAPIException(str(q))
635         _update_cache(_artists, a)
636         lookup = a
637     return found + lookup
638
639 def get_album_list(album_ids):
640     """Returns: [Album]"""
641     assert(isinstance(album_ids, list))
642     found = []
643     lookup = []
644     for album_id in album_ids:
645         a = _albums.get(album_id, None)
646         if not a:
647             lookup.append(album_id)
648         else:
649             found.append(a)
650     if lookup:
651         q = GetQuery('album_list', '+'.join(str(x) for x in lookup))
652         a = q.execute()
653         if not a:
654             raise JamendoAPIException(str(q))
655         _update_cache(_albums, a)
656         lookup = a
657     return found + lookup
658
659 def get_albums(artist_id):
660     """Returns: [Album]
661     Parameter can either be an artist_id or a list of album ids.
662     """
663     if isinstance(artist_id, list):
664         return get_album_list(artist_id)
665     a = _artists.get(artist_id, None)
666     if a and a.albums:
667         return a.albums
668
669     q = GetQuery('albums', artist_id)
670     a = q.execute()
671     if not a:
672         raise JamendoAPIException(str(q))
673     _update_cache(_albums, a)
674     return a
675
676 def get_album(album_id):
677     """Returns: Album"""
678     a = _albums.get(album_id, None)
679     if not a:
680         q = GetQuery('album', album_id)
681         a = q.execute()
682         if not a:
683             raise JamendoAPIException(str(q))
684         _update_cache(_albums, a)
685         if isinstance(a, list):
686             a = a[0]
687     return a
688
689 def get_track_list(track_ids):
690     """Returns: [Track]"""
691     assert(isinstance(track_ids, list))
692     found = []
693     lookup = []
694     for track_id in track_ids:
695         a = _tracks.get(track_id, None)
696         if not a:
697             lookup.append(track_id)
698         else:
699             found.append(a)
700     if lookup:
701         q = GetQuery('track_list', '+'.join(str(x) for x in lookup))
702         a = q.execute()
703         if not a:
704             raise JamendoAPIException(str(q))
705         _update_cache(_tracks, a)
706         lookup = a
707     return found + lookup
708
709 def get_tracks(album_id):
710     """Returns: [Track]
711     Parameter can either be an album_id or a list of track ids.
712     """
713     if isinstance(album_id, list):
714         return get_track_list(album_id)
715     a = _albums.get(album_id, None)
716     if a and a.tracks:
717         return a.tracks
718
719     q = GetQuery('tracks', album_id)
720     a = q.execute()
721     if not a:
722         raise JamendoAPIException(str(q))
723     _update_cache(_tracks, a)
724     return a
725
726 def get_track(track_id):
727     """Returns: Track"""
728     a = _tracks.get(track_id, None)
729     if not a:
730         q = GetQuery('track', track_id)
731         a = q.execute()
732         if not a:
733             raise JamendoAPIException(str(q))
734         _update_cache(_tracks, a)
735         if isinstance(a, list):
736             a = a[0]
737     return a
738
739 def get_radio_tracks(radio_id):
740     """Returns: [Track]"""
741     q = GetQuery('radio', radio_id)
742     a = q.execute()
743     if not a:
744         raise JamendoAPIException(str(q))
745     _update_cache(_tracks, a)
746     return a
747
748 #http://api.jamendo.com/get2/id+name/track/plain/?tag_id=327&n=50&order=rating_desc
749 def get_tag_tracks(tag_id):
750     """Returns: [Track]"""
751     q = GetQuery('tag', tag_id)
752     a = q.execute()
753     if not a:
754         raise JamendoAPIException(str(q))
755     _update_cache(_tracks, a)
756     return a
757
758 def search_artists(query):
759     """Returns: [Artist]"""
760     q = SearchQuery('artist', query, 'searchweight_desc')
761     a = q.execute()
762     if not a:
763         raise JamendoAPIException(str(q))
764     _update_cache(_artists, a)
765     return a
766
767 def search_albums(query):
768     """Returns: [Album]"""
769     q = SearchQuery('album', query, 'searchweight_desc')
770     a = q.execute()
771     if not a:
772         raise JamendoAPIException(str(q))
773     _update_cache(_albums, a)
774     return a
775
776 def search_tracks(query):
777     """Returns: [Track]"""
778     q = SearchQuery('track', query=query, order='searchweight_desc')
779     a = q.execute()
780     if not a:
781         raise JamendoAPIException(str(q))
782     _update_cache(_tracks, a)
783     return a
784
785 def albums_of_the_week():
786     """Returns: [Album]"""
787     q = SearchQuery('album', order='ratingweek_desc')
788     a = q.execute()
789     if not a:
790         raise JamendoAPIException(str(q))
791     _update_cache(_albums, a)
792     return a
793
794 def new_releases():
795     """Returns: [Track] (playlist)"""
796     q = SearchQuery('track', order='releasedate_desc')
797     a = q.execute()
798     if not a:
799         raise JamendoAPIException(str(q))
800     _update_cache(_tracks, a)
801     return a
802
803 def tracks_of_the_week():
804     """Returns: [Track] (playlist)"""
805     q = SearchQuery('track', order='ratingweek_desc')
806     a = q.execute()
807     if not a:
808         raise JamendoAPIException(str(q))
809     _update_cache(_tracks, a)
810     return a
811
812 def top_artists(order='rating_desc', count=20):
813     """Returns: [Artist]"""
814     q = SearchQuery('artist', order=order, count=count)
815     a = q.execute()
816     if not a:
817         raise JamendoAPIException(str(q))
818     _update_cache(_artists, a)
819     return a
820
821 def top_albums(order='rating_desc', count=20):
822     """Returns: [Album]"""
823     q = SearchQuery('album', order=order, count=count)
824     a = q.execute()
825     if not a:
826         raise JamendoAPIException(str(q))
827     _update_cache(_albums, a)
828     return a
829
830 def top_tracks(order='rating_desc', count=20):
831     """Returns: [Track]"""
832     q = SearchQuery('track', order=order, count=count)
833     a = q.execute()
834     if not a:
835         raise JamendoAPIException(str(q))
836     _update_cache(_tracks, a)
837     return a
838
839 def get_radio(radio_id):
840     """Returns: Radio"""
841     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?id=%d"%(radio_id))
842     js = q.execute()
843     if not js:
844         raise JamendoAPIException(str(q))
845     if isinstance(js, list):
846         ks = js[0]
847     return Radio(radio_id, json=js)
848
849 def starred_radios():
850     """Returns: [Radio]"""
851     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?order=starred_desc")
852     js = q.execute()
853     if not js:
854         raise JamendoAPIException(str(q))
855     return [Radio(int(radio['id']), json=radio) for radio in js]
856
857 def top_tags(count=50, order='rating_desc'):
858     """Returns: [Tag]"""
859     q = CustomQuery(_GET2+"id+name/tag/json?n=%d&order=%s"%(count, order))
860     js = q.execute()
861     if not js:
862         raise JamendoAPIException(str(q))
863     return [Tag(int(tag['id']), json=tag) for tag in js]
864
865 def favorite_albums(user):
866     """Returns: [Album]"""
867     q = SearchQuery('favorite_albums', user=user, count=20)
868     a = q.execute()
869     if not a:
870         raise JamendoAPIException(str(q))
871     _update_cache(_albums, a)
872     return a
873
874 ### Set loader functions for classes
875
876 def _artist_loader(self):
877     if self._needs_load():
878         artist = get_artist(self.ID)
879         artist.albums = get_albums(self.ID)
880         self._set_from(artist)
881 Artist.load = _artist_loader
882
883 def _album_loader(self):
884     if self._needs_load():
885         album = get_album(self.ID)
886         album.tracks = get_tracks(self.ID)
887         self._set_from(album)
888 Album.load = _album_loader
889
890 def _track_loader(self):
891     track = get_track(self.ID)
892     self._set_from(track)
893 Track.load = _track_loader
894
895 def _radio_loader(self):
896     radio = get_radio(self.ID)
897     self._set_from(radio)
898 Radio.load = _radio_loader