Tons of fixes/tweaks/changes in general
[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_id', '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_id = None
156         self.artist_name = None
157         self.album_name = None
158         self.album_id = None
159         self.numalbum = None
160         self.duration = None
161         if json:
162             self.set_from_json(json)
163
164     def mp3_url(self):
165        return _MP3URL%(self.ID)
166
167     def ogg_url(self):
168        return _OGGURL%(self.ID)
169
170     def _needs_load(self):
171         return self._needs_load_impl('name', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
172
173     def _set_from(self, other):
174         return self._set_from_impl(other, 'name', 'image', 'artist_name', 'artist_id', 'album_name', 'album_id', 'numalbum', 'duration')
175
176 class Radio(LazyQuery):
177     def __init__(self, ID, json=None):
178         self.ID = int(ID)
179         self.name = None
180         self.idstr = None
181         self.image = None
182         if json:
183             self.set_from_json(json)
184
185     def _needs_load(self):
186         return self._needs_load_impl('name', 'idstr', 'image')
187
188     def _set_from(self, other):
189         return self._set_from_impl(other, 'name', 'idstr', 'image')
190
191
192 _artists = {} # id -> Artist()
193 _albums = {} # id -> Album()
194 _tracks = {} # id -> Track()
195 _radios = {} # id -> Radio()
196
197
198 # cache sizes per session (TODO)
199 _CACHED_ARTISTS = 100
200 _CACHED_ALBUMS = 200
201 _CACHED_TRACKS = 500
202 _CACHED_RADIOS = 10
203
204 # TODO: cache queries?
205
206 class Query(object):
207     rate_limit = 1.1 # seconds between queries
208     last_query = time.time() - 1.5
209
210     @classmethod
211     def _ratelimit(cls):
212         now = time.time()
213         if now - cls.last_query < cls.rate_limit:
214             time.sleep(cls.rate_limit - (now - cls.last_query))
215         cls.last_query = now
216
217     def __init__(self):
218         pass
219
220     def _geturl(self, url):
221         print "*** %s" % (url)
222         Query._ratelimit()
223         try:
224             f = urllib.urlopen(url)
225             ret = simplejson.load(f)
226             f.close()
227         except Exception, e:
228             return None
229         return ret
230
231     def __str__(self):
232         return "#%s" % (self.__class__.__name__)
233
234     def execute(self):
235         raise NotImplemented
236
237 import threading
238
239 class CoverFetcher(threading.Thread):
240     def __init__(self):
241         threading.Thread.__init__(self)
242         self.setDaemon(True)
243         self.cond = threading.Condition()
244         self.work = []
245
246     def _fetch_cover(self, albumid, size):
247         try:
248             coverdir = _COVERDIR if _COVERDIR else '/tmp'
249             to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
250             if not os.path.isfile(to):
251                 url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
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 run(self):
264         while True:
265             work = []
266             self.cond.acquire()
267             while True:
268                 work = self.work
269                 if work:
270                     self.work = []
271                     break
272                 self.cond.wait()
273             self.cond.release()
274
275             multi = len(work) > 1
276             for albumid, size, cb in work:
277                 cover = self._fetch_cover(albumid, size)
278                 if cover:
279                     cb(albumid, size, cover)
280                     if multi:
281                         time.sleep(1.0)
282
283 class CoverCache(object):
284     """
285     cache and fetch covers
286     TODO: background thread that
287     fetches and returns covers,
288     asynchronously, LIFO
289     """
290     def __init__(self):
291         self._covers = {} # (albumid, size) -> file
292         coverdir = _COVERDIR if _COVERDIR else '/tmp'
293         if os.path.isdir(coverdir):
294             covermatch = re.compile(r'(\d+)\-(\d+)\.jpg')
295             for fil in os.listdir(coverdir):
296                 fl = os.path.join(coverdir, fil)
297                 m = covermatch.match(fil)
298                 if m and os.path.isfile(fl):
299                     self._covers[(int(m.group(1)), int(m.group(2)))] = fl
300         self._fetcher = CoverFetcher()
301         self._fetcher.start()
302
303     def fetch_cover(self, albumid, size):
304         coverdir = _COVERDIR if _COVERDIR else '/tmp'
305         to = os.path.join(coverdir, '%d-%d.jpg'%(albumid, size))
306         if not os.path.isfile(to):
307             url = _GET2+'image/album/redirect/?id=%d&imagesize=%d'%(albumid, size)
308             urllib.urlretrieve(url, to)
309             self._covers[(albumid, size)] = to
310         return to
311
312     def get_cover(self, albumid, size):
313         cover = self._covers.get((albumid, size), None)
314         if not cover:
315             cover = self.fetch_cover(albumid, size)
316         return cover
317
318     def get_async(self, albumid, size, cb):
319         cover = self._covers.get((albumid, size), None)
320         if cover:
321             cb(albumid, size, cover)
322         else:
323             self._fetcher.request_cover(albumid, size, cb)
324
325 _cover_cache = CoverCache()
326
327 def get_album_cover(albumid, size=100):
328     return _cover_cache.get_cover(albumid, size)
329
330 def get_album_cover_async(cb, albumid, size=100):
331     _cover_cache.get_async(albumid, size, cb)
332
333 class CustomQuery(Query):
334     def __init__(self, url):
335         Query.__init__(self)
336         self.url = url
337
338     def execute(self):
339         return self._geturl(self.url)
340
341     def __str__(self):
342         return self.url
343
344 class GetQuery(Query):
345     queries = {
346         'artist' : {
347             'url' : _GET2+'+'.join(_ARTIST_FIELDS)+'/artist/json/?',
348             'params' : 'artist_id=%d',
349             'constructor' : Artist
350             },
351         'artist_list' : {
352             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/artist/json/?',
353             'params' : 'artist_id=%s',
354             'constructor' : Album
355             },
356         'album' : {
357             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
358             'params' : 'album_id=%d',
359             'constructor' : Album
360             },
361         'album_list' : {
362             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
363             'params' : 'album_id=%s',
364             'constructor' : Album
365             },
366         'albums' : {
367             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/?',
368             'params' : 'artist_id=%d',
369             'constructor' : [Album]
370             },
371         'track' : {
372             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
373             'params' : 'id=%d',
374             'constructor' : Track
375             },
376         'track_list' : {
377             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
378             'params' : 'id=%s',
379             'constructor' : Track
380             },
381         'tracks' : {
382             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/track_album+album_artist?',
383             'params' : 'order=numalbum_asc&album_id=%d',
384             'constructor' : [Track]
385             },
386         'radio' : {
387             'url' : _GET2+'+'.join(_TRACK_FIELDS)+'/track/json/radio_track_inradioplaylist+track_album+album_artist/?',
388             'params' : 'order=random_asc&radio_id=%d',
389             'constructor' : [Track]
390             },
391         'favorite_albums' : {
392             'url' : _GET2+'+'.join(_ALBUM_FIELDS)+'/album/json/album_user_starred/?',
393             'params' : 'user_idstr=%s',
394             'constructor' : [Album]
395             },
396     #http://api.jamendo.com/get2/id+name+url+image+artist_name/album/jsonpretty/album_user_starred/?user_idstr=sylvinus&n=all
397     #q = SearchQuery('album', user_idstr=user)
398
399         }
400 #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
401
402     def __init__(self, what, ID):
403         Query.__init__(self)
404         self.ID = ID
405         info = GetQuery.queries[what]
406         self.url = info['url']
407         self.params = info['params']
408         self.constructor = info['constructor']
409
410     def construct(self, data):
411         constructor = self.constructor
412         if isinstance(constructor, list):
413             constructor = constructor[0]
414         if isinstance(data, list):
415             return [constructor(int(x['id']), json=x) for x in data]
416         else:
417             return constructor(int(data['id']), json=data)
418
419     def execute(self):
420         js = self._geturl(self.url + self.params % (self.ID))
421         if not js:
422             return None
423         return self.construct(js)
424
425     def __str__(self):
426         return self.url + self.params % (self.ID)
427
428 class SearchQuery(GetQuery):
429     def __init__(self, what, query=None, order=None, user=None, count=10):
430         GetQuery.__init__(self, what, None)
431         self.query = query
432         self.order = order
433         self.count = count
434         self.user = user
435
436     def execute(self):
437         params = {}
438         if self.query:
439             params['searchquery'] = self.query
440         if self.order:
441             params['order'] = self.order
442         if self.count:
443             params['n'] = self.count
444         if self.user:
445             params['user_idstr'] = self.user
446         js = self._geturl(self.url +  urllib.urlencode(params))
447         if not js:
448             return None
449         return self.construct(js)
450
451     def __str__(self):
452         params = {'searchquery':self.query, 'order':self.order, 'n':self.count}
453         return self.url +  urllib.urlencode(params)
454
455 class JamendoAPIException(Exception):
456     def __init__(self, url):
457         Exception.__init__(self, url)
458
459 def _update_cache(cache, new_items):
460     if not isinstance(new_items, list):
461         new_items = [new_items]
462     for item in new_items:
463         old = cache.get(item.ID)
464         if old:
465             old._set_from(item)
466         else:
467             cache[item.ID] = item
468         if isinstance(item, Artist) and item.albums:
469             for album in item.albums:
470                 _update_cache(_albums, album)
471         elif isinstance(item, Album) and item.tracks:
472             for track in item.tracks:
473                 _update_cache(_tracks, track)
474
475 def get_artist(artist_id):
476     """Returns: Artist"""
477     a = _artists.get(artist_id, None)
478     if not a:
479         q = GetQuery('artist', artist_id)
480         a = q.execute()
481         if not a:
482             raise JamendoAPIException(str(q))
483         _update_cache(_artists, a)
484         if isinstance(a, list):
485             a = a[0]
486     return a
487
488 def get_artists(artist_ids):
489     """Returns: [Artist]"""
490     assert(isinstance(artist_ids, list))
491     found = []
492     lookup = []
493     for artist_id in artist_ids:
494         a = _artists.get(artist_id, None)
495         if not a:
496             lookup.append(artist_id)
497         else:
498             found.append(a)
499     if lookup:
500         q = GetQuery('artist_list', '+'.join(str(x) for x in lookup))
501         a = q.execute()
502         if not a:
503             raise JamendoAPIException(str(q))
504         _update_cache(_artists, a)
505         lookup = a
506     return found + lookup
507
508 def get_album_list(album_ids):
509     """Returns: [Album]"""
510     assert(isinstance(album_ids, list))
511     found = []
512     lookup = []
513     for album_id in album_ids:
514         a = _albums.get(album_id, None)
515         if not a:
516             lookup.append(album_id)
517         else:
518             found.append(a)
519     if lookup:
520         q = GetQuery('album_list', '+'.join(str(x) for x in lookup))
521         a = q.execute()
522         if not a:
523             raise JamendoAPIException(str(q))
524         _update_cache(_albums, a)
525         lookup = a
526     return found + lookup
527
528 def get_albums(artist_id):
529     """Returns: [Album]
530     Parameter can either be an artist_id or a list of album ids.
531     """
532     if isinstance(artist_id, list):
533         return get_album_list(artist_id)
534     a = _artists.get(artist_id, None)
535     if a and a.albums:
536         return a.albums
537
538     q = GetQuery('albums', artist_id)
539     a = q.execute()
540     if not a:
541         raise JamendoAPIException(str(q))
542     _update_cache(_artists, a)
543     return a
544
545 def get_album(album_id):
546     """Returns: Album"""
547     a = _albums.get(album_id, None)
548     if not a:
549         q = GetQuery('album', album_id)
550         a = q.execute()
551         if not a:
552             raise JamendoAPIException(str(q))
553         _update_cache(_albums, a)
554         if isinstance(a, list):
555             a = a[0]
556     return a
557
558 def get_track_list(track_ids):
559     """Returns: [Track]"""
560     assert(isinstance(track_ids, list))
561     found = []
562     lookup = []
563     for track_id in track_ids:
564         a = _tracks.get(track_id, None)
565         if not a:
566             lookup.append(track_id)
567         else:
568             found.append(a)
569     if lookup:
570         q = GetQuery('track_list', '+'.join(str(x) for x in lookup))
571         a = q.execute()
572         if not a:
573             raise JamendoAPIException(str(q))
574         _update_cache(_tracks, a)
575         lookup = a
576     return found + lookup
577
578 def get_tracks(album_id):
579     """Returns: [Track]
580     Parameter can either be an album_id or a list of track ids.
581     """
582     if isinstance(album_id, list):
583         return get_track_list(album_id)
584     a = _albums.get(album_id, None)
585     if a and a.tracks:
586         return a.tracks
587
588     q = GetQuery('tracks', album_id)
589     a = q.execute()
590     if not a:
591         raise JamendoAPIException(str(q))
592     _update_cache(_tracks, a)
593     return a
594
595 def get_track(track_id):
596     """Returns: Track"""
597     a = _tracks.get(track_id, None)
598     if not a:
599         q = GetQuery('track', track_id)
600         a = q.execute()
601         if not a:
602             raise JamendoAPIException(str(q))
603         _update_cache(_tracks, a)
604         if isinstance(a, list):
605             a = a[0]
606     return a
607
608 def get_radio_tracks(radio_id):
609     """Returns: [Track]"""
610     q = GetQuery('radio', radio_id)
611     a = q.execute()
612     if not a:
613         raise JamendoAPIException(str(q))
614     _update_cache(_tracks, a)
615     return a
616
617 def search_artists(query):
618     """Returns: [Artist]"""
619     q = SearchQuery('artist', query, 'searchweight_desc')
620     a = q.execute()
621     if not a:
622         raise JamendoAPIException(str(q))
623     _update_cache(_artists, a)
624     return a
625
626 def search_albums(query):
627     """Returns: [Album]"""
628     q = SearchQuery('album', query, 'searchweight_desc')
629     a = q.execute()
630     if not a:
631         raise JamendoAPIException(str(q))
632     _update_cache(_albums, a)
633     return a
634
635 def search_tracks(query):
636     """Returns: [Track]"""
637     q = SearchQuery('track', query=query, order='searchweight_desc')
638     a = q.execute()
639     if not a:
640         raise JamendoAPIException(str(q))
641     _update_cache(_tracks, a)
642     return a
643
644 def albums_of_the_week():
645     """Returns: [Album]"""
646     q = SearchQuery('album', order='ratingweek_desc')
647     a = q.execute()
648     if not a:
649         raise JamendoAPIException(str(q))
650     _update_cache(_albums, a)
651     return a
652
653 def new_releases():
654     """Returns: [Track] (playlist)"""
655     q = SearchQuery('track', order='releasedate_desc')
656     a = q.execute()
657     if not a:
658         raise JamendoAPIException(str(q))
659     _update_cache(_tracks, a)
660     return a
661
662 def tracks_of_the_week():
663     """Returns: [Track] (playlist)"""
664     q = SearchQuery('track', order='ratingweek_desc')
665     a = q.execute()
666     if not a:
667         raise JamendoAPIException(str(q))
668     _update_cache(_tracks, a)
669     return a
670
671 def get_radio(radio_id):
672     """Returns: Radio"""
673     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?id=%d"%(radio_id))
674     js = q.execute()
675     if not js:
676         raise JamendoAPIException(str(q))
677     if isinstance(js, list):
678         ks = js[0]
679     return Radio(radio_id, json=js)
680
681 def starred_radios():
682     """Returns: [Radio]"""
683     q = CustomQuery(_GET2+"id+name+idstr+image/radio/json?order=starred_desc")
684     js = q.execute()
685     if not js:
686         raise JamendoAPIException(str(q))
687     return [Radio(int(radio['id']), json=radio) for radio in js]
688
689 def favorite_albums(user):
690     """Returns: [Album]"""
691     q = SearchQuery('favorite_albums', user=user, count=20)
692     a = q.execute()
693     if not a:
694         raise JamendoAPIException(str(q))
695     _update_cache(_albums, a)
696     return a
697
698 ### Set loader functions for classes
699
700 def _artist_loader(self):
701     if self._needs_load():
702         artist = get_artist(self.ID)
703         artist.albums = get_albums(self.ID)
704         self._set_from(artist)
705 Artist.load = _artist_loader
706
707 def _album_loader(self):
708     if self._needs_load():
709         album = get_album(self.ID)
710         album.tracks = get_tracks(self.ID)
711         self._set_from(album)
712 Album.load = _album_loader
713
714 def _track_loader(self):
715     track = get_track(self.ID)
716     self._set_from(track)
717 Track.load = _track_loader
718
719 def _radio_loader(self):
720     radio = get_radio(self.ID)
721     self._set_from(radio)
722 Radio.load = _radio_loader