3 # This file is part of Jamaendo.
4 # Copyright (c) 2010, Kristoffer Gronlund
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.
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.
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
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'
42 log = logging.getLogger(__name__)
44 class StdoutLogger(object):
45 def info(self, s, *args):
47 def debug(self, s, *args):
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
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']
60 class LazyQuery(object):
61 def set_from_json(self, json):
62 for key, value in json.iteritems():
64 assert(self.ID == int(value))
66 if key.endswith('_id'):
68 setattr(self, key, value)
71 """Not automatic for now,
72 will have to do artist.load()
74 This is filled in further down
79 def _needs_load(self):
82 def _set_from(self, other):
85 def _needs_load_impl(self, *attrs):
87 if getattr(self, attr) is None:
91 def _set_from_impl(self, other, *attrs):
93 self._set_if(other, attr)
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))
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:
105 #traceback.print_exc()
106 return u"%s(?)"%(self.__class__.__name__)
108 class Artist(LazyQuery):
109 def __init__(self, ID, json=None):
113 self.albums = None # None means not downloaded
115 self.set_from_json(json)
117 def _needs_load(self):
118 return self._needs_load_impl('name', 'albums')
120 def _set_from(self, other):
121 return self._set_from_impl(other, 'name', 'image', 'albums')
123 class Album(LazyQuery):
124 def __init__(self, ID, json=None):
128 self.artist_name = None
129 self.artist_id = None
130 self.license_url = None
131 self.tracks = None # None means not downloaded
133 self.set_from_json(json)
135 def torrent_url(self):
136 return _TORRENTURL%(self.ID)
139 def _needs_load(self):
140 return self._needs_load_impl('name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
142 def _set_from(self, other):
143 return self._set_from_impl(other, 'name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
145 class Track(LazyQuery):
146 def __init__(self, ID, json=None):
150 self.artist_id = None
151 self.artist_name = None
152 self.album_name = None
157 self.set_from_json(json)
160 return _MP3URL%(self.ID)
163 return _OGGURL%(self.ID)
165 def _needs_load(self):
166 return self._needs_load_impl('name', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
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')
171 class Radio(LazyQuery):
172 def __init__(self, ID, json=None):
178 self.set_from_json(json)
180 def _needs_load(self):
181 return self._needs_load_impl('name', 'idstr', 'image')
183 def _set_from(self, other):
184 return self._set_from_impl(other, 'name', 'idstr', 'image')
187 _artists = {} # id -> Artist()
188 _albums = {} # id -> Album()
189 _tracks = {} # id -> Track()
190 _radios = {} # id -> Radio()
193 # cache sizes per session (TODO)
194 _CACHED_ARTISTS = 100
198 # cache sizes, persistant
199 _CACHED_COVERS = 2048
201 # TODO: cache queries?
204 rate_limit = 1.1 # seconds between queries
205 last_query = time.time() - 1.5
210 if now - cls.last_query < cls.rate_limit:
211 time.sleep(cls.rate_limit - (now - cls.last_query))
217 def _geturl(self, url):
221 f = urllib.urlopen(url)
222 ret = simplejson.load(f)
229 return "#%s" % (self.__class__.__name__)
234 class CoverFetcher(threading.Thread):
236 threading.Thread.__init__(self)
238 self.cond = threading.Condition()
241 def _fetch_cover(self, albumid, size):
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)
252 def request_cover(self, albumid, size, cb):
254 self.work.insert(0, (albumid, size, cb))
270 multi = len(work) > 1
271 for albumid, size, cb in work:
272 cover = self._fetch_cover(albumid, size)
274 cb(albumid, size, cover)
278 class CoverCache(object):
280 cache and fetch covers
281 TODO: background thread that
282 fetches and returns covers,
286 self._covers = {} # (albumid, size) -> file
287 self._fetcher = CoverFetcher()
288 self._fetcher.start()
289 if _COVERDIR and os.path.isdir(_COVERDIR):
292 def prime_cache(self):
294 covermatch = re.compile(r'(\d+)\-(\d+)\.jpg')
296 prev_covers = os.listdir(coverdir)
298 if len(prev_covers) > _CACHED_COVERS:
300 dropn = len(prev_covers) - _CACHED_COVERS
301 todrop = random.sample(prev_covers, dropn)
302 log.warning("Deleting from cache: %s", todrop)
304 m = covermatch.match(d)
307 os.unlink(os.path.join(coverdir, d))
309 log.exception('unlinking failed')
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
317 def fetch_cover(self, albumid, size):
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
328 def get_cover(self, albumid, size):
329 cover = self._covers.get((albumid, size), None)
331 cover = self.fetch_cover(albumid, size)
334 def get_async(self, albumid, size, cb):
335 cover = self._covers.get((albumid, size), None)
337 cb(albumid, size, cover)
339 self._fetcher.request_cover(albumid, size, cb)
341 _cover_cache = CoverCache()
343 def set_cache_dir(cachedir):
347 _COVERDIR = os.path.join(_CACHEDIR, 'covers')
350 os.makedirs(_CACHEDIR)
355 os.makedirs(_COVERDIR)
359 _cover_cache.prime_cache()
361 def get_album_cover(albumid, size=100):
362 return _cover_cache.get_cover(albumid, size)
364 def get_album_cover_async(cb, albumid, size=100):
365 _cover_cache.get_async(albumid, size, cb)
367 class CustomQuery(Query):
368 def __init__(self, url):
373 return self._geturl(self.url)
378 class GetQuery(Query):
381 'url' : _GET2+'+'.join(_ARTIST_FIELDS)+'/artist/json/?',
382 'params' : 'artist_id=%d',
383 'constructor' : Artist
386 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/artist/json/?',
387 'params' : 'artist_id=%s',
388 'constructor' : Album
391 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
392 'params' : 'album_id=%d',
393 'constructor' : Album
396 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
397 'params' : 'album_id=%s',
398 'constructor' : Album
401 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
402 'params' : 'artist_id=%d',
403 'constructor' : [Album]
406 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
408 'constructor' : Track
411 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
413 'constructor' : Track
416 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
417 'params' : 'order=numalbum_asc&album_id=%d',
418 'constructor' : [Track]
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]
425 'favorite_albums' : {
426 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/album_user_starred/?',
427 'params' : 'user_idstr=%s',
428 'constructor' : [Album]
432 def __init__(self, what, ID):
435 info = GetQuery.queries[what]
436 self.url = info['url']
437 self.params = info['params']
438 self.constructor = info['constructor']
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]
447 return constructor(int(data['id']), json=data)
450 js = self._geturl(self.url + self.params % (self.ID))
453 return self.construct(js)
456 return self.url + self.params % (self.ID)
458 class SearchQuery(GetQuery):
459 def __init__(self, what, query=None, order=None, user=None, count=10):
460 GetQuery.__init__(self, what, None)
469 params['searchquery'] = self.query
471 params['order'] = self.order
473 params['n'] = self.count
475 params['user_idstr'] = self.user
476 js = self._geturl(self.url + urllib.urlencode(params))
479 return self.construct(js)
482 params = {'searchquery':self.query, 'order':self.order, 'n':self.count}
483 return self.url + urllib.urlencode(params)
485 class JamendoAPIException(Exception):
486 def __init__(self, url):
487 Exception.__init__(self, url)
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)
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..
509 # artists: 1 day - changes often
510 # albums: 2-5 days - changes less often (?)
511 # tracks: 1 week - changes rarely, queried often
513 def get_artist(artist_id):
514 """Returns: Artist"""
515 a = _artists.get(artist_id, None)
517 q = GetQuery('artist', artist_id)
520 raise JamendoAPIException(str(q))
521 _update_cache(_artists, a)
522 if isinstance(a, list):
526 def get_artists(artist_ids):
527 """Returns: [Artist]"""
528 assert(isinstance(artist_ids, list))
531 for artist_id in artist_ids:
532 a = _artists.get(artist_id, None)
534 lookup.append(artist_id)
538 q = GetQuery('artist_list', '+'.join(str(x) for x in lookup))
541 raise JamendoAPIException(str(q))
542 _update_cache(_artists, a)
544 return found + lookup
546 def get_album_list(album_ids):
547 """Returns: [Album]"""
548 assert(isinstance(album_ids, list))
551 for album_id in album_ids:
552 a = _albums.get(album_id, None)
554 lookup.append(album_id)
558 q = GetQuery('album_list', '+'.join(str(x) for x in lookup))
561 raise JamendoAPIException(str(q))
562 _update_cache(_albums, a)
564 return found + lookup
566 def get_albums(artist_id):
568 Parameter can either be an artist_id or a list of album ids.
570 if isinstance(artist_id, list):
571 return get_album_list(artist_id)
572 a = _artists.get(artist_id, None)
576 q = GetQuery('albums', artist_id)
579 raise JamendoAPIException(str(q))
580 _update_cache(_artists, a)
583 def get_album(album_id):
585 a = _albums.get(album_id, None)
587 q = GetQuery('album', album_id)
590 raise JamendoAPIException(str(q))
591 _update_cache(_albums, a)
592 if isinstance(a, list):
596 def get_track_list(track_ids):
597 """Returns: [Track]"""
598 assert(isinstance(track_ids, list))
601 for track_id in track_ids:
602 a = _tracks.get(track_id, None)
604 lookup.append(track_id)
608 q = GetQuery('track_list', '+'.join(str(x) for x in lookup))
611 raise JamendoAPIException(str(q))
612 _update_cache(_tracks, a)
614 return found + lookup
616 def get_tracks(album_id):
618 Parameter can either be an album_id or a list of track ids.
620 if isinstance(album_id, list):
621 return get_track_list(album_id)
622 a = _albums.get(album_id, None)
626 q = GetQuery('tracks', album_id)
629 raise JamendoAPIException(str(q))
630 _update_cache(_tracks, a)
633 def get_track(track_id):
635 a = _tracks.get(track_id, None)
637 q = GetQuery('track', track_id)
640 raise JamendoAPIException(str(q))
641 _update_cache(_tracks, a)
642 if isinstance(a, list):
646 def get_radio_tracks(radio_id):
647 """Returns: [Track]"""
648 q = GetQuery('radio', radio_id)
651 raise JamendoAPIException(str(q))
652 _update_cache(_tracks, a)
655 def search_artists(query):
656 """Returns: [Artist]"""
657 q = SearchQuery('artist', query, 'searchweight_desc')
660 raise JamendoAPIException(str(q))
661 _update_cache(_artists, a)
664 def search_albums(query):
665 """Returns: [Album]"""
666 q = SearchQuery('album', query, 'searchweight_desc')
669 raise JamendoAPIException(str(q))
670 _update_cache(_albums, a)
673 def search_tracks(query):
674 """Returns: [Track]"""
675 q = SearchQuery('track', query=query, order='searchweight_desc')
678 raise JamendoAPIException(str(q))
679 _update_cache(_tracks, a)
682 def albums_of_the_week():
683 """Returns: [Album]"""
684 q = SearchQuery('album', order='ratingweek_desc')
687 raise JamendoAPIException(str(q))
688 _update_cache(_albums, a)
692 """Returns: [Track] (playlist)"""
693 q = SearchQuery('track', order='releasedate_desc')
696 raise JamendoAPIException(str(q))
697 _update_cache(_tracks, a)
700 def tracks_of_the_week():
701 """Returns: [Track] (playlist)"""
702 q = SearchQuery('track', order='ratingweek_desc')
705 raise JamendoAPIException(str(q))
706 _update_cache(_tracks, a)
709 def get_radio(radio_id):
711 q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?id=%d"%(radio_id))
714 raise JamendoAPIException(str(q))
715 if isinstance(js, list):
717 return Radio(radio_id, json=js)
719 def starred_radios():
720 """Returns: [Radio]"""
721 q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?order=starred_desc")
724 raise JamendoAPIException(str(q))
725 return [Radio(int(radio['id']), json=radio) for radio in js]
727 def favorite_albums(user):
728 """Returns: [Album]"""
729 q = SearchQuery('favorite_albums', user=user, count=20)
732 raise JamendoAPIException(str(q))
733 _update_cache(_albums, a)
736 ### Set loader functions for classes
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
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
752 def _track_loader(self):
753 track = get_track(self.ID)
754 self._set_from(track)
755 Track.load = _track_loader
757 def _radio_loader(self):
758 radio = get_radio(self.ID)
759 self._set_from(radio)
760 Radio.load = _radio_loader