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