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