44a47e1fee9616162e03bea4323bf91f7d868ce7
[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, gzip, time, simplejson, re
32
33 _CACHEDIR = None
34 _COVERDIR = None
35 _GET2 = '''http://api.jamendo.com/get2/'''
36 _MP3URL = _GET2+'stream/track/redirect/?id=%d&streamencoding=mp31'
37 _OGGURL = _GET2+'stream/track/redirect/?id=%d&streamencoding=ogg2'
38 _TORRENTURL = _GET2+'bittorrent/file/redirect/?album_id=%d&type=archive&class=mp32'
39
40 def set_cache_dir(cachedir):
41     global _CACHEDIR
42     global _COVERDIR
43     _CACHEDIR = cachedir
44     _COVERDIR = os.path.join(_CACHEDIR, 'covers')
45
46     try:
47         os.makedirs(_CACHEDIR)
48     except OSError:
49         pass
50
51     try:
52         os.makedirs(_COVERDIR)
53     except OSError:
54         pass
55
56 # These classes can be partially constructed,
57 # and if asked for a property they don't know,
58 # makes a query internally to get the full story
59
60 _ARTIST_FIELDS = ['id', 'name', 'image']
61 _ALBUM_FIELDS = ['id', 'name', 'image', 'artist_name', 'artist_id', 'license_url']
62 _TRACK_FIELDS = ['id', 'name', 'image', 'artist_name', 'album_name', 'album_id', 'numalbum', 'duration']
63 _RADIO_FIELDS = ['id', 'name', 'idstr', 'image']
64
65 class LazyQuery(object):
66     def set_from_json(self, json):
67         for key, value in json.iteritems():
68             if key == 'id':
69                 assert(self.ID == int(value))
70             else:
71                 if key.endswith('_id'):
72                     value = int(value)
73                 setattr(self, key, value)
74
75     def load(self):
76         """Not automatic for now,
77         will have to do artist.load()
78
79         This is filled in further down
80         in the file
81         """
82         raise NotImplemented
83
84     def _needs_load(self):
85         return True
86
87     def _set_from(self, other):
88         raise NotImplemented
89
90     def _needs_load_impl(self, *attrs):
91         for attr in attrs:
92             if getattr(self, attr) is None:
93                 return True
94         return False
95
96     def _set_from_impl(self, other, *attrs):
97         for attr in attrs:
98             self._set_if(other, attr)
99
100     def _set_if(self, other, attrname):
101         if getattr(self, attrname) is None and getattr(other, attrname) is not None:
102             setattr(self, attrname, getattr(other, attrname))
103
104     def __repr__(self):
105         try:
106             return u"%s(%s)"%(self.__class__.__name__,
107                               u", ".join(repr(v) for k,v in self.__dict__.iteritems() if not k.startswith('_')))
108         except UnicodeEncodeError:
109             #import traceback
110             #traceback.print_exc()
111             return u"%s(?)"%(self.__class__.__name__)
112
113 class Artist(LazyQuery):
114     def __init__(self, ID, json=None):
115         self.ID = int(ID)
116         self.name = None
117         self.image = None
118         self.albums = None # None means not downloaded
119         if json:
120             self.set_from_json(json)
121
122     def _needs_load(self):
123         return self._needs_load_impl('name', 'albums')
124
125     def _set_from(self, other):
126         return self._set_from_impl(other, 'name', 'image', 'albums')
127
128 class Album(LazyQuery):
129     def __init__(self, ID, json=None):
130         self.ID = int(ID)
131         self.name = None
132         self.image = None
133         self.artist_name = None
134         self.artist_id = None
135         self.license_url = None
136         self.tracks = None # None means not downloaded
137         if json:
138             self.set_from_json(json)
139
140     def torrent_url(self):
141         return _TORRENTURL%(self.ID)
142
143
144     def _needs_load(self):
145         return self._needs_load_impl('name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
146
147     def _set_from(self, other):
148         return self._set_from_impl(other, 'name', 'image', 'artist_name', 'artist_id', 'license_url', 'tracks')
149
150 class Track(LazyQuery):
151     def __init__(self, ID, json=None):
152         self.ID = int(ID)
153         self.name = None
154         self.image = None
155         self.artist_name = None
156         self.album_name = None
157         self.album_id = None
158         self.numalbum = None
159         self.duration = None
160         if json:
161             self.set_from_json(json)
162
163     def mp3_url(self):
164        return _MP3URL%(self.ID)
165
166     def ogg_url(self):
167        return _OGGURL%(self.ID)
168
169     def _needs_load(self):
170         return self._needs_load_impl('name', 'artist_name', 'album_name', 'album_id', 'numalbum', 'duration')
171
172     def _set_from(self, other):
173         return self._set_from_impl(other, 'name', 'image', 'artist_name', 'album_name', 'album_id', 'numalbum', 'duration')
174
175 class Radio(LazyQuery):
176     def __init__(self, ID, json=None):
177         self.ID = int(ID)
178         self.name = None
179         self.idstr = None
180         self.image = None
181         if json:
182             self.set_from_json(json)
183
184     def _needs_load(self):
185         return self._needs_load_impl('name', 'idstr', 'image')
186
187     def _set_from(self, other):
188         return self._set_from_impl(other, 'name', 'idstr', 'image')
189
190
191 _artists = {} # id -> Artist()
192 _albums = {} # id -> Album()
193 _tracks = {} # id -> Track()
194 _radios = {} # id -> Radio()
195
196
197 # cache sizes per session (TODO)
198 _CACHED_ARTISTS = 100
199 _CACHED_ALBUMS = 200
200 _CACHED_TRACKS = 500
201 _CACHED_RADIOS = 10
202
203 # TODO: cache queries?
204
205 class Query(object):
206     rate_limit = 1.1 # seconds between queries
207     last_query = time.time() - 1.5
208
209     @classmethod
210     def _ratelimit(cls):
211         now = time.time()
212         if now - cls.last_query < cls.rate_limit:
213             time.sleep(cls.rate_limit - (now - cls.last_query))
214         cls.last_query = now
215
216     def __init__(self):
217         pass
218
219     def _geturl(self, url):
220         #print "*** %s" % (url)
221         Query._ratelimit()
222         try:
223             f = urllib.urlopen(url)
224             ret = simplejson.load(f)
225             f.close()
226         except Exception, e:
227             return None
228         return ret
229
230     def __str__(self):
231         return "#%s" % (self.__class__.__name__)
232
233     def execute(self):
234         raise NotImplemented
235
236 import threading
237
238 class CoverFetcher(threading.Thread):
239     def __init__(self):
240         threading.Thread.__init__(self)
241         self.setDaemon(True)
242         self.cond = threading.Condition()
243         self.work = []
244
245     def _fetch_cover(self, albumid, size):
246         try:
247             coverdir = _COVERDIR if _COVERDIR else '/tmp'
248             to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
249             if not os.path.isfile(to):
250                 url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
251                 urllib.urlretrieve(url, to)
252             return to
253         except Exception, e:
254             return None
255
256     def request_cover(self, albumid, size, cb):
257         self.cond.acquire()
258         self.work.insert(0, (albumid, size, cb))
259         self.cond.notify()
260         self.cond.release()
261
262     def run(self):
263         while True:
264             work = []
265             self.cond.acquire()
266             while True:
267                 work = self.work
268                 if work:
269                     self.work = []
270                     break
271                 self.cond.wait()
272             self.cond.release()
273
274             multi = len(work) > 1
275             for albumid, size, cb in work:
276                 cover = self._fetch_cover(albumid, size)
277                 if cover:
278                     cb(albumid, size, cover)
279                     if multi:
280                         time.sleep(1.0)
281
282 class CoverCache(object):
283     """
284     cache and fetch covers
285     TODO: background thread that
286     fetches and returns covers,
287     asynchronously, LIFO
288     """
289     def __init__(self):
290         self._covers = {} # (albumid, size) -> file
291         coverdir = _COVERDIR if _COVERDIR else '/tmp'
292         if os.path.isdir(coverdir):
293             covermatch = re.compile(r'(\d+)\-(\d+)\.jpg')
294             for fil in os.listdir(coverdir):
295                 fl = os.path.join(coverdir, fil)
296                 m = covermatch.match(fil)
297                 if m and os.path.isfile(fl):
298                     self._covers[(int(m.group(1)), int(m.group(2)))] = fl
299         self._fetcher = CoverFetcher()
300         self._fetcher.start()
301
302     def fetch_cover(self, albumid, size):
303         coverdir = _COVERDIR if _COVERDIR else '/tmp'
304         to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
305         if not os.path.isfile(to):
306             url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
307             urllib.urlretrieve(url, to)
308             self._covers[(albumid, size)] = to
309         return to
310
311     def get_cover(self, albumid, size):
312         cover = self._covers.get((albumid, size), None)
313         if not cover:
314             cover = self.fetch_cover(albumid, size)
315         return cover
316
317     def get_async(self, albumid, size, cb):
318         cover = self._covers.get((albumid, size), None)
319         if cover:
320             cb(albumid, size, cover)
321         else:
322             self._fetcher.request_cover(albumid, size, cb)
323
324 _cover_cache = CoverCache()
325
326 def get_album_cover(albumid, size=100):
327     return _cover_cache.get_cover(albumid, size)
328
329 def get_album_cover_async(cb, albumid, size=100):
330     _cover_cache.get_async(albumid, size, cb)
331
332 class CustomQuery(Query):
333     def __init__(self, url):
334         Query.__init__(self)
335         self.url = url
336
337     def execute(self):
338         return self._geturl(self.url)
339
340     def __str__(self):
341         return self.url
342
343 class GetQuery(Query):
344     queries = {
345         'artist' : {
346             'url' : _GET2+'+'.join(_ARTIST_FIELDS)+'/artist/json/?',
347             'params' : 'artist_id=%d',
348             'constructor' : Artist
349             },
350         'album' : {
351             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
352             'params' : 'album_id=%d',
353             'constructor' : Album
354             },
355         'albums' : {
356             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
357             'params' : 'artist_id=%d',
358             'constructor' : [Album]
359             },
360         'track' : {
361             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
362             'params' : 'id=%d',
363             'constructor' : Track
364             },
365         'tracks' : {
366             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
367             'params' : 'order=numalbum_asc&album_id=%d',
368             'constructor' : [Track]
369             },
370         'radio' : {
371             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/radio_track_inradioplaylist+track_album+album_artist/?',
372             'params' : 'order=random_asc&radio_id=%d',
373             'constructor' : [Track]
374             },
375         'favorite_albums' : {
376             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/album_user_starred/?',
377             'params' : 'user_idstr=%s',
378             'constructor' : [Album]
379             },
380     #http://api.jamendo.com/get2/id+name+url+image+artist_name/album/jsonpretty/album_user_starred/?user_idstr=sylvinus&n=all
381     #q = SearchQuery('album', user_idstr=user)
382
383         }
384 #http://api.jamendo.com/get2/id+name+image+artist_name+album_name+album_id+numalbum+duration/track/json/radio_track_inradioplaylist+track_album+album_artist/?order=numradio_asc&radio_id=283
385
386     def __init__(self, what, ID):
387         Query.__init__(self)
388         self.ID = ID
389         info = GetQuery.queries[what]
390         self.url = info['url']
391         self.params = info['params']
392         self.constructor = info['constructor']
393
394     def construct(self, data):
395         constructor = self.constructor
396         if isinstance(constructor, list):
397             constructor = constructor[0]
398         if isinstance(data, list):
399             return [constructor(int(x['id']), json=x) for x in data]
400         else:
401             return constructor(int(data['id']), json=data)
402
403     def execute(self):
404         js = self._geturl(self.url + self.params % (self.ID))
405         if not js:
406             return None
407         return self.construct(js)
408
409     def __str__(self):
410         return self.url + self.params % (self.ID)
411
412 class SearchQuery(GetQuery):
413     def __init__(self, what, query=None, order=None, user=None, count=10):
414         GetQuery.__init__(self, what, None)
415         self.query = query
416         self.order = order
417         self.count = count
418         self.user = user
419
420     def execute(self):
421         params = {}
422         if self.query:
423             params['searchquery'] = self.query
424         if self.order:
425             params['order'] = self.order
426         if self.count:
427             params['n'] = self.count
428         if self.user:
429             params['user_idstr'] = self.user
430         js = self._geturl(self.url +  urllib.urlencode(params))
431         if not js:
432             return None
433         return self.construct(js)
434
435     def __str__(self):
436         params = {'searchquery':self.query, 'order':self.order, 'n':self.count}
437         return self.url +  urllib.urlencode(params)
438
439 class JamendoAPIException(Exception):
440     def __init__(self, url):
441         Exception.__init__(self, url)
442
443 def _update_cache(cache, new_items):
444     if not isinstance(new_items, list):
445         new_items = [new_items]
446     for item in new_items:
447         old = cache.get(item.ID)
448         if old:
449             old._set_from(item)
450         else:
451             cache[item.ID] = item
452
453 def get_artist(artist_id):
454     """Returns: Artist"""
455     a = _artists.get(artist_id, None)
456     if not a:
457         q = GetQuery('artist', artist_id)
458         a = q.execute()
459         if not a:
460             raise JamendoAPIException(str(q))
461         _update_cache(_artists, a)
462         if isinstance(a, list):
463             a = a[0]
464     return a
465
466 def get_albums(artist_id):
467     """Returns: [Album]"""
468     q = GetQuery('albums', artist_id)
469     a = q.execute()
470     if not a:
471         raise JamendoAPIException(str(q))
472     _update_cache(_artists, a)
473     return a
474
475 def get_album(album_id):
476     """Returns: Album"""
477     a = _albums.get(album_id, None)
478     if not a:
479         q = GetQuery('album', album_id)
480         a = q.execute()
481         if not a:
482             raise JamendoAPIException(str(q))
483         _update_cache(_albums, a)
484         if isinstance(a, list):
485             a = a[0]
486     return a
487
488 def get_tracks(album_id):
489     """Returns: [Track]"""
490     q = GetQuery('tracks', album_id)
491     a = q.execute()
492     if not a:
493         raise JamendoAPIException(str(q))
494     _update_cache(_tracks, a)
495     return a
496
497 def get_track(track_id):
498     """Returns: Track"""
499     a = _tracks.get(track_id, None)
500     if not a:
501         q = GetQuery('track', track_id)
502         a = q.execute()
503         if not a:
504             raise JamendoAPIException(str(q))
505         _update_cache(_tracks, a)
506         if isinstance(a, list):
507             a = a[0]
508     return a
509
510 def get_radio_tracks(radio_id):
511     """Returns: [Track]"""
512     q = GetQuery('radio', radio_id)
513     a = q.execute()
514     if not a:
515         raise JamendoAPIException(str(q))
516     _update_cache(_tracks, a)
517     return a
518
519 def search_artists(query):
520     """Returns: [Artist]"""
521     q = SearchQuery('artist', query, 'searchweight_desc')
522     a = q.execute()
523     if not a:
524         raise JamendoAPIException(str(q))
525     _update_cache(_artists, a)
526     return a
527
528 def search_albums(query):
529     """Returns: [Album]"""
530     q = SearchQuery('album', query, 'searchweight_desc')
531     a = q.execute()
532     if not a:
533         raise JamendoAPIException(str(q))
534     _update_cache(_albums, a)
535     return a
536
537 def search_tracks(query):
538     """Returns: [Track]"""
539     q = SearchQuery('track', query=query, order='searchweight_desc')
540     a = q.execute()
541     if not a:
542         raise JamendoAPIException(str(q))
543     _update_cache(_tracks, a)
544     return a
545
546 def albums_of_the_week():
547     """Returns: [Album]"""
548     q = SearchQuery('album', order='ratingweek_desc')
549     a = q.execute()
550     if not a:
551         raise JamendoAPIException(str(q))
552     _update_cache(_albums, a)
553     return a
554
555 def new_releases():
556     """Returns: [Track] (playlist)"""
557     q = SearchQuery('track', order='releasedate_desc')
558     a = q.execute()
559     if not a:
560         raise JamendoAPIException(str(q))
561     _update_cache(_tracks, a)
562     return a
563
564 def tracks_of_the_week():
565     """Returns: [Track] (playlist)"""
566     q = SearchQuery('track', order='ratingweek_desc')
567     a = q.execute()
568     if not a:
569         raise JamendoAPIException(str(q))
570     _update_cache(_tracks, a)
571     return a
572
573 def get_radio(radio_id):
574     """Returns: Radio"""
575     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?id=%d"%(radio_id))
576     js = q.execute()
577     if not js:
578         raise JamendoAPIException(str(q))
579     if isinstance(js, list):
580         ks = js[0]
581     return Radio(radio_id, json=js)
582
583 def starred_radios():
584     """Returns: [Radio]"""
585     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?order=starred_desc")
586     js = q.execute()
587     if not js:
588         raise JamendoAPIException(str(q))
589     return [Radio(int(radio['id']), json=radio) for radio in js]
590
591 def favorite_albums(user):
592     """Returns: [Album]"""
593     q = SearchQuery('favorite_albums', user=user, count=20)
594     a = q.execute()
595     if not a:
596         raise JamendoAPIException(str(q))
597     _update_cache(_albums, a)
598     return a
599
600 ### Set loader functions for classes
601
602 def _artist_loader(self):
603     if self._needs_load():
604         artist = get_artist(self.ID)
605         self._set_from(artist)
606 Artist.load = _artist_loader
607
608 def _album_loader(self):
609     if self._needs_load():
610         album = get_album(self.ID)
611         album.tracks = get_tracks(self.ID)
612         self._set_from(album)
613 Album.load = _album_loader
614
615 def _track_loader(self):
616     track = get_track(self.ID)
617     self._set_from(track)
618 Track.load = _track_loader
619
620 def _radio_loader(self):
621     radio = get_radio(self.ID)
622     self._set_from(radio)
623 Radio.load = _radio_loader