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.
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.
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
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'
36 log = logging.getLogger(__name__)
38 class StdoutLogger(object):
39 def info(self, s, *args):
41 def debug(self, s, *args):
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
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']
54 class LazyQuery(object):
55 def set_from_json(self, json):
56 for key, value in json.iteritems():
58 assert(self.ID == int(value))
60 if key.endswith('_id'):
62 setattr(self, key, value)
65 """Not automatic for now,
66 will have to do artist.load()
68 This is filled in further down
73 def _needs_load(self):
76 def _set_from(self, other):
79 def _needs_load_impl(self, *attrs):
81 if getattr(self, attr) is None:
85 def _set_from_impl(self, other, *attrs):
87 self._set_if(other, attr)
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))
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:
99 #traceback.print_exc()
100 return u"%s(?)"%(self.__class__.__name__)
102 class Artist(LazyQuery):
103 def __init__(self, ID, json=None):
107 self.albums = None # None means not downloaded
109 self.set_from_json(json)
111 def _needs_load(self):
112 return self._needs_load_impl('name', 'albums')
114 def _set_from(self, other):
115 return self._set_from_impl(other, 'name', 'image', 'albums')
117 class Album(LazyQuery):
118 def __init__(self, ID, json=None):
122 self.artist_name = None
123 self.artist_id = None
124 self.license_url = None
125 self.tracks = None # None means not downloaded
127 self.set_from_json(json)
129 def torrent_url(self):
130 return _TORRENTURL%(self.ID)
133 def _needs_load(self):
134 return self._needs_load_impl('name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
136 def _set_from(self, other):
137 return self._set_from_impl(other, 'name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
139 class Track(LazyQuery):
140 def __init__(self, ID, json=None):
144 self.artist_id = None
145 self.artist_name = None
146 self.album_name = None
151 self.set_from_json(json)
154 return _MP3URL%(self.ID)
157 return _OGGURL%(self.ID)
159 def _needs_load(self):
160 return self._needs_load_impl('name', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
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')
165 class Radio(LazyQuery):
166 def __init__(self, ID, json=None):
172 self.set_from_json(json)
174 def _needs_load(self):
175 return self._needs_load_impl('name', 'idstr', 'image')
177 def _set_from(self, other):
178 return self._set_from_impl(other, 'name', 'idstr', 'image')
181 _artists = {} # id -> Artist()
182 _albums = {} # id -> Album()
183 _tracks = {} # id -> Track()
184 _radios = {} # id -> Radio()
187 # cache sizes per session (TODO)
188 _CACHED_ARTISTS = 100
192 # cache sizes, persistant
193 _CACHED_COVERS = 2048
195 # TODO: cache queries?
198 rate_limit = 1.1 # seconds between queries
199 last_query = time.time() - 1.5
204 if now - cls.last_query < cls.rate_limit:
205 time.sleep(cls.rate_limit - (now - cls.last_query))
211 def _geturl(self, url):
215 f = urllib.urlopen(url)
216 ret = simplejson.load(f)
223 return "#%s" % (self.__class__.__name__)
228 class CoverFetcher(threading.Thread):
230 threading.Thread.__init__(self)
232 self.cond = threading.Condition()
235 def _fetch_cover(self, albumid, size):
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)
246 def _fetch_image(self, url):
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)
257 def request_cover(self, albumid, size, cb):
259 self.work.insert(0, (albumid, size, cb))
263 def request_images(self, urls, cb):
264 """cb([(url, image)])"""
266 self.work.insert(0, ('images', urls, cb))
282 multi = len(work) > 1
284 if job[0] == 'images':
285 self.process_images(job[1], job[2])
287 self.process_cover(*job)
291 def process_cover(self, albumid, size, cb):
292 albumid, size, cb = job
293 cover = self._fetch_cover(albumid, size)
295 cb(albumid, size, cover)
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]
302 class CoverCache(object):
304 cache and fetch covers
305 TODO: background thread that
306 fetches and returns covers,
310 self._covers = {} # (albumid, size) -> file
312 self._fetcher = CoverFetcher()
313 self._fetcher.start()
314 if _COVERDIR and os.path.isdir(_COVERDIR):
317 def prime_cache(self):
319 covermatch = re.compile(r'(\d+)\-(\d+)\.jpg')
321 prev_covers = os.listdir(coverdir)
323 if len(prev_covers) > _CACHED_COVERS:
325 dropn = len(prev_covers) - _CACHED_COVERS
326 todrop = random.sample(prev_covers, dropn)
327 log.warning("Deleting from cache: %s", todrop)
329 m = covermatch.match(d)
332 os.unlink(os.path.join(coverdir, d))
334 log.exception('unlinking failed')
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
342 def fetch_cover(self, albumid, size):
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
353 def get_cover(self, albumid, size):
354 cover = self._covers.get((albumid, size), None)
356 cover = self.fetch_cover(albumid, size)
359 def get_async(self, albumid, size, cb):
360 cover = self._covers.get((albumid, size), None)
362 cb(albumid, size, cover)
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)
369 def get_images_async(self, url_list, cb):
373 image = self._images.get(url, None)
375 found.append((url, image))
383 for url, image in results:
384 self._images[url] = image
386 self._fetcher.request_images(lookup, cb2)
388 _cover_cache = CoverCache()
390 def set_cache_dir(cachedir):
394 _COVERDIR = os.path.join(_CACHEDIR, 'covers')
397 os.makedirs(_CACHEDIR)
402 os.makedirs(_COVERDIR)
406 _cover_cache.prime_cache()
408 def get_album_cover(albumid, size=100):
409 return _cover_cache.get_cover(albumid, size)
411 def get_album_cover_async(cb, albumid, size=100):
412 _cover_cache.get_async(albumid, size, cb)
414 def get_images_async(cb, url_list):
415 _cover_cache.get_images_async(url_list, cb)
417 class CustomQuery(Query):
418 def __init__(self, url):
423 return self._geturl(self.url)
428 class GetQuery(Query):
431 'url' : _GET2+'+'.join(_ARTIST_FIELDS)+'/artist/json/?',
432 'params' : 'artist_id=%d',
433 'constructor' : Artist
436 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/artist/json/?',
437 'params' : 'artist_id=%s',
438 'constructor' : Album
441 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
442 'params' : 'album_id=%d',
443 'constructor' : Album
446 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
447 'params' : 'album_id=%s',
448 'constructor' : Album
451 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
452 'params' : 'artist_id=%d',
453 'constructor' : [Album]
456 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
458 'constructor' : Track
461 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
463 'constructor' : Track
466 'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
467 'params' : 'order=numalbum_asc&album_id=%d',
468 'constructor' : [Track]
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]
475 'favorite_albums' : {
476 'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/album_user_starred/?',
477 'params' : 'user_idstr=%s',
478 'constructor' : [Album]
482 def __init__(self, what, ID):
485 info = GetQuery.queries[what]
486 self.url = info['url']
487 self.params = info['params']
488 self.constructor = info['constructor']
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]
497 return constructor(int(data['id']), json=data)
500 js = self._geturl(self.url + self.params % (self.ID))
503 return self.construct(js)
506 return self.url + self.params % (self.ID)
508 class SearchQuery(GetQuery):
509 def __init__(self, what, query=None, order=None, user=None, count=10):
510 GetQuery.__init__(self, what, None)
519 params['searchquery'] = self.query
521 params['order'] = self.order
523 params['n'] = self.count
525 params['user_idstr'] = self.user
526 js = self._geturl(self.url + urllib.urlencode(params))
529 return self.construct(js)
532 params = {'searchquery':self.query, 'order':self.order, 'n':self.count}
533 return self.url + urllib.urlencode(params)
535 class JamendoAPIException(Exception):
536 def __init__(self, url):
537 Exception.__init__(self, url)
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)
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..
559 # artists: 1 day - changes often
560 # albums: 2-5 days - changes less often (?)
561 # tracks: 1 week - changes rarely, queried often
563 def get_artist(artist_id):
564 """Returns: Artist"""
565 a = _artists.get(artist_id, None)
567 q = GetQuery('artist', artist_id)
570 raise JamendoAPIException(str(q))
571 _update_cache(_artists, a)
572 if isinstance(a, list):
576 def get_artists(artist_ids):
577 """Returns: [Artist]"""
578 assert(isinstance(artist_ids, list))
581 for artist_id in artist_ids:
582 a = _artists.get(artist_id, None)
584 lookup.append(artist_id)
588 q = GetQuery('artist_list', '+'.join(str(x) for x in lookup))
591 raise JamendoAPIException(str(q))
592 _update_cache(_artists, a)
594 return found + lookup
596 def get_album_list(album_ids):
597 """Returns: [Album]"""
598 assert(isinstance(album_ids, list))
601 for album_id in album_ids:
602 a = _albums.get(album_id, None)
604 lookup.append(album_id)
608 q = GetQuery('album_list', '+'.join(str(x) for x in lookup))
611 raise JamendoAPIException(str(q))
612 _update_cache(_albums, a)
614 return found + lookup
616 def get_albums(artist_id):
618 Parameter can either be an artist_id or a list of album ids.
620 if isinstance(artist_id, list):
621 return get_album_list(artist_id)
622 a = _artists.get(artist_id, None)
626 q = GetQuery('albums', artist_id)
629 raise JamendoAPIException(str(q))
630 _update_cache(_artists, a)
633 def get_album(album_id):
635 a = _albums.get(album_id, None)
637 q = GetQuery('album', album_id)
640 raise JamendoAPIException(str(q))
641 _update_cache(_albums, a)
642 if isinstance(a, list):
646 def get_track_list(track_ids):
647 """Returns: [Track]"""
648 assert(isinstance(track_ids, list))
651 for track_id in track_ids:
652 a = _tracks.get(track_id, None)
654 lookup.append(track_id)
658 q = GetQuery('track_list', '+'.join(str(x) for x in lookup))
661 raise JamendoAPIException(str(q))
662 _update_cache(_tracks, a)
664 return found + lookup
666 def get_tracks(album_id):
668 Parameter can either be an album_id or a list of track ids.
670 if isinstance(album_id, list):
671 return get_track_list(album_id)
672 a = _albums.get(album_id, None)
676 q = GetQuery('tracks', album_id)
679 raise JamendoAPIException(str(q))
680 _update_cache(_tracks, a)
683 def get_track(track_id):
685 a = _tracks.get(track_id, None)
687 q = GetQuery('track', track_id)
690 raise JamendoAPIException(str(q))
691 _update_cache(_tracks, a)
692 if isinstance(a, list):
696 def get_radio_tracks(radio_id):
697 """Returns: [Track]"""
698 q = GetQuery('radio', radio_id)
701 raise JamendoAPIException(str(q))
702 _update_cache(_tracks, a)
705 def search_artists(query):
706 """Returns: [Artist]"""
707 q = SearchQuery('artist', query, 'searchweight_desc')
710 raise JamendoAPIException(str(q))
711 _update_cache(_artists, a)
714 def search_albums(query):
715 """Returns: [Album]"""
716 q = SearchQuery('album', query, 'searchweight_desc')
719 raise JamendoAPIException(str(q))
720 _update_cache(_albums, a)
723 def search_tracks(query):
724 """Returns: [Track]"""
725 q = SearchQuery('track', query=query, order='searchweight_desc')
728 raise JamendoAPIException(str(q))
729 _update_cache(_tracks, a)
732 def albums_of_the_week():
733 """Returns: [Album]"""
734 q = SearchQuery('album', order='ratingweek_desc')
737 raise JamendoAPIException(str(q))
738 _update_cache(_albums, a)
742 """Returns: [Track] (playlist)"""
743 q = SearchQuery('track', order='releasedate_desc')
746 raise JamendoAPIException(str(q))
747 _update_cache(_tracks, a)
750 def tracks_of_the_week():
751 """Returns: [Track] (playlist)"""
752 q = SearchQuery('track', order='ratingweek_desc')
755 raise JamendoAPIException(str(q))
756 _update_cache(_tracks, a)
759 def get_radio(radio_id):
761 q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?id=%d"%(radio_id))
764 raise JamendoAPIException(str(q))
765 if isinstance(js, list):
767 return Radio(radio_id, json=js)
769 def starred_radios():
770 """Returns: [Radio]"""
771 q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?order=starred_desc")
774 raise JamendoAPIException(str(q))
775 return [Radio(int(radio['id']), json=radio) for radio in js]
777 def favorite_albums(user):
778 """Returns: [Album]"""
779 q = SearchQuery('favorite_albums', user=user, count=20)
782 raise JamendoAPIException(str(q))
783 _update_cache(_albums, a)
786 ### Set loader functions for classes
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
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
802 def _track_loader(self):
803 track = get_track(self.ID)
804 self._set_from(track)
805 Track.load = _track_loader
807 def _radio_loader(self):
808 radio = get_radio(self.ID)
809 self._set_from(radio)
810 Radio.load = _radio_loader