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