PageRenderTime 38ms CodeModel.GetById 30ms RepoModel.GetById 0ms app.codeStats 1ms

/clients/deezer/deezerproxy/tizmopidydeezer.py

https://bitbucket.org/tizonia/tizonia-openmax-il
Python | 395 lines | 302 code | 64 blank | 29 comment | 16 complexity | 74b47b2b1ac7b35c90d28d060a0953f4 MD5 | raw file
Possible License(s): LGPL-3.0, Apache-2.0, MIT, 0BSD
  1. # -*- coding: utf-8 -*-
  2. # Copyright (C) Konstantin Batura <https://github.com/rusty-dev>
  3. #
  4. # Original Author: Konstantin Batura <https://github.com/rusty-dev>
  5. #
  6. # Portions Copyright (C) 2017 Aratelia Limited - Juan A. Rubio
  7. #
  8. # Licensed under the Apache License, Version 2.0 (the "License");
  9. # you may not use this file except in compliance with the License.
  10. # You may obtain a copy of the License at
  11. #
  12. # http://www.apache.org/licenses/LICENSE-2.0
  13. #
  14. # Unless required by applicable law or agreed to in writing, software
  15. # distributed under the License is distributed on an "AS IS" BASIS,
  16. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  17. # See the License for the specific language governing permissions and
  18. # limitations under the License.
  19. #
  20. # Original code: https://github.com/rusty-dev/mopidy-deezer
  21. #
  22. # List of modifications:
  23. # - Replaced mopidy models to avoid havin mopidy as a dependency.
  24. # - Stripped functions for track, artist and album image retrieval.
  25. # - Removed track storage.
  26. #
  27. import logging
  28. import random
  29. import re
  30. import string
  31. from array import array
  32. from io import BytesIO
  33. from operator import xor
  34. from struct import pack
  35. from Crypto.Cipher import AES, Blowfish
  36. from eyed3.mp3.headers import Mp3Header, findHeader, timePerFrame
  37. from tizdeezermodels import Album, Artist, Track, Playlist
  38. import requests
  39. import simplejson as json
  40. from tizmopidydeezerutils import get_md5
  41. logger = logging.getLogger(__name__)
  42. def load_artist(data):
  43. return Artist(
  44. name=data['ART_NAME'],
  45. uri='deezer:artist:%s' % data['ART_ID']
  46. )
  47. def load_album(data, artist):
  48. return Album(
  49. name=data['ALB_TITLE'],
  50. uri='deezer:album:%s' % data['ALB_ID'],
  51. date=data.get('DIGITAL_RELEASE_DATE', None),
  52. artists=[artist],
  53. )
  54. def load_track(data, album, artist):
  55. return Track(
  56. name=data['SNG_TITLE'],
  57. uri='deezer:track:%s' % data['SNG_ID'],
  58. length=int(data['DURATION']) * 1000,
  59. album=album,
  60. artists=[artist]
  61. )
  62. def load_tracklist(tracklist):
  63. tracks = []
  64. for track in tracklist:
  65. artist = load_artist(track)
  66. album = load_album(track, artist)
  67. tracks.append(load_track(track, album, artist))
  68. return tracks
  69. def format_playlist_title(title, fans, songs):
  70. return u'{} (♥{} ♫{})'.format(title, fans, songs)
  71. class DeezerClient(object):
  72. def __init__(self, user_id):
  73. super(DeezerClient, self).__init__()
  74. self._user_id = user_id
  75. self.session = requests.session()
  76. self._api_token = None
  77. @property
  78. def api_token(self):
  79. if self._api_token is not None:
  80. return self._api_token
  81. data = self.session.get('http://www.deezer.com').text
  82. try:
  83. self._api_token = re.search(r'<input id="checkForm" name="checkForm" type="hidden" value="([\w~\-.]+)">', data).group(1)
  84. return self._api_token
  85. except:
  86. return None
  87. def _api_call(self, method, **params):
  88. get_query = {
  89. 'api_version': '1.0',
  90. 'api_token': self.api_token,
  91. 'input': 3,
  92. 'cid': self.get_cid()
  93. }
  94. # apparently, it's possible to batch multiple methodos in one request, but nobody uses that...
  95. post_query = [{
  96. 'method': method,
  97. 'params': params
  98. }]
  99. endpoint = "http://www.deezer.com/ajax/gw-light.php?"
  100. response = self.session.post(
  101. endpoint,
  102. params=get_query,
  103. data=json.dumps(post_query, separators=(',', ':'))
  104. )
  105. data = json.loads(response.text)
  106. # on critical errors, it returns a dict instead of list, go figure.
  107. if isinstance(data, list):
  108. data = data[0]
  109. return data
  110. def api_call(self, method, **params):
  111. for _ in range(2):
  112. data = self._api_call(method, **params)
  113. if data['error']:
  114. logger.error('Error during deezer api call, method: %s, params: %s, error: %s', method, params, data['error'])
  115. # invalidate current api token if it's outdated
  116. if data['error'].get('VALID_TOKEN_REQUIRED', None):
  117. self._api_token = None
  118. continue
  119. break
  120. return data['results']
  121. def search(self, query):
  122. data = self.api_call('deezer.pageSearch', query=query, top_tracks=True, start=0, nb=40)
  123. artists = [load_artist(artist) for artist in data['ARTIST']['data']]
  124. albums = [load_album(album, load_artist(album['ARTISTS'][0])) for album in data['ALBUM']['data']]
  125. tracks = load_tracklist(data['TRACK']['data'])
  126. return artists, albums, tracks
  127. def lookup_track(self, track_id):
  128. track_id = int(track_id)
  129. data = self.get_track(track_id)
  130. artist = load_artist(data)
  131. album = load_album(data, artist)
  132. return [load_track(data, album, artist)]
  133. def lookup_album(self, album_id):
  134. data = self.api_call('song.getListByAlbum', alb_id=album_id, nb=500)
  135. tracks = load_tracklist(data['data'])
  136. return tracks
  137. def lookup_artist(self, artist_id):
  138. data = self.api_call('deezer.pageArtist', art_id=artist_id, lang='en')
  139. tracklist = []
  140. for album in data['ALBUMS']['data']:
  141. tracklist.extend(album['SONGS']['data'])
  142. tracks = load_tracklist(tracklist)
  143. return tracks
  144. def lookup_radio(self, radio_id):
  145. data = self.api_call('radio.getSongs', radio_id=radio_id)
  146. return load_tracklist(data['data'])
  147. def lookup_user_radio(self, user_id):
  148. data = self.api_call('radio.getUserRadio', user_id=user_id)
  149. return load_tracklist(data['data'])
  150. def lookup_playlist_raw(self, playlist_id):
  151. data = self.api_call('playlist.getSongs', playlist_id=playlist_id, start=0, nb=1000)
  152. return data['data']
  153. def lookup_playlist(self, playlist_id):
  154. return load_tracklist(self.lookup_playlist_raw(playlist_id))
  155. def browse_artists(self):
  156. data = self.api_call('deezer.pageProfile', user_id=self._user_id, tab="artists", nb=1000)
  157. artists = []
  158. for artist in data['TAB']['artists']['data']:
  159. artists.append(Artist(
  160. name=artist['ART_NAME'],
  161. uri='deezer:artist:%s' % artist['ART_ID']
  162. ))
  163. artists.sort(key=lambda artist: artist.name)
  164. return artists
  165. def browse_albums(self):
  166. data = self.api_call('deezer.pageProfile', user_id=self._user_id, tab="albums", nb=1000)
  167. albums = []
  168. for album in data['TAB']['albums']['data']:
  169. albums.append(Album(
  170. name='%s - %s' % (album['ART_NAME'], album['ALB_TITLE']),
  171. uri='deezer:album:%s' % album['ALB_ID']
  172. ))
  173. albums.sort(key=lambda album: album.name)
  174. return albums
  175. def browse_playlists(self):
  176. data = self.api_call(
  177. 'deezer.pageProfile',
  178. user_id=self._user_id,
  179. tab='playlists',
  180. nb=40)
  181. return data['TAB']['playlists']['data']
  182. def browse_flow(self):
  183. tracks = []
  184. for track in self.lookup_user_radio(self._user_id):
  185. tracks.append(Track(
  186. name=track.name,
  187. uri=track.uri
  188. ))
  189. return tracks
  190. def browse_mixes(self):
  191. data = self.api_call('radio.getSortedList')
  192. mixes = []
  193. for radio in data['data']:
  194. mixes.append(Playlist(
  195. name=radio['TITLE'],
  196. uri='deezer:radio:%s' % radio['RADIO_ID'])
  197. )
  198. return mixes
  199. def browse_top_playlists(self):
  200. data = self.api_call('deezer.pageTops', type='playlist', genre_id=0, start=0, nb=100, lang='en')
  201. playlists = []
  202. for playlist in data['ITEMS']['data']:
  203. playlists.append(Playlist(
  204. name=format_playlist_title(playlist['TITLE'], playlist['NB_FAN'], playlist['NB_SONG']),
  205. uri='deezer:playlist:%s' % playlist['PLAYLIST_ID'])
  206. )
  207. return playlists
  208. def browse_moods(self):
  209. data = self.api_call('deezer.pageMoods', type='playlist', genre_id=0, start=0, nb=100)
  210. playlists = []
  211. for playlist in data['ITEMS']['data']:
  212. playlists.append(Playlist(
  213. name=format_playlist_title(playlist['TITLE'], playlist['NB_FAN'], playlist['NB_SONG']),
  214. uri='deezer:playlist:%s' % playlist['PLAYLIST_ID'])
  215. )
  216. return playlists
  217. @staticmethod
  218. def get_cid():
  219. source = string.digits + string.ascii_lowercase
  220. return ''.join(random.choice(source) for _ in xrange(18))
  221. def get_track(self, track_id):
  222. if not track_id:
  223. return None
  224. logger.info('Single track lookup, track_id=%s' % track_id)
  225. data = self.api_call('song.getData', sng_id=track_id)
  226. if 'FALLBACK' in data:
  227. data.update(data['FALLBACK'])
  228. return data
  229. def get_track_cipher(self, track_id):
  230. """ Get track-specific cipher from `track_id` """
  231. track_md5 = get_md5(str(track_id).lstrip('-'))
  232. key_parts = map(lambda v: array('B', v), ('g4el58wc0zvf9na1', track_md5[:16], track_md5[16:]))
  233. blowfish_key = b''.join(chr(reduce(xor, x)) for x in zip(*key_parts))
  234. IV = pack('B' * 8, *range(8))
  235. def track_cipher():
  236. return Blowfish.new(blowfish_key, mode=Blowfish.MODE_CBC, IV=IV)
  237. return track_cipher
  238. def get_track_url(self, data):
  239. join = u'\u00a4'.join
  240. proxy = data['MD5_ORIGIN'][0]
  241. if data['FILESIZE_MP3_320']:
  242. track_format = 3
  243. elif data['FILESIZE_MP3_256']:
  244. track_format = 5
  245. else:
  246. track_format = 1
  247. payload = join(map(str, [data['MD5_ORIGIN'], track_format, data['SNG_ID'], data['MEDIA_VERSION']]))
  248. payloadHash = get_md5(payload)
  249. def pad(s, BS=16):
  250. return s + (BS - len(s) % BS) * chr(BS - len(s) % BS)
  251. reference = pad(join([payloadHash, payload, '']).encode('latin-1'))
  252. cipher = AES.new('jo6aey6haid2Teih', mode=AES.MODE_ECB)
  253. reference = cipher.encrypt(reference).encode('hex').lower()
  254. return "http://e-cdn-proxy-{}.deezer.com/mobile/1/{}".format(proxy, reference)
  255. def _stream(self, track_cipher, track_url):
  256. logger.info('Streaming track: %s' % track_url)
  257. def decyption_stream():
  258. chunk_size = 2048
  259. inp = self.session.get(track_url, stream=True)
  260. i = 0
  261. for chunk in inp.iter_content(chunk_size):
  262. if not chunk:
  263. continue
  264. if i % 3 == 0 and len(chunk) == chunk_size:
  265. cipher = track_cipher() # reset cipher state each time.
  266. chunk = cipher.decrypt(chunk)
  267. i += 1
  268. yield chunk
  269. yield None # yield None for priming.
  270. chunks = decyption_stream()
  271. send_frames = 1024 # amount frames to sent on each iteration
  272. # read mp3 header
  273. header = None
  274. first_chunk = b''
  275. while not header:
  276. first_chunk += next(chunks)
  277. header_buffer = BytesIO(first_chunk)
  278. header_pos, header, header_bytes = findHeader(header_buffer)
  279. del header_buffer
  280. h = Mp3Header(header)
  281. ms_per_frame = timePerFrame(h, False) * 1000
  282. send_size = send_frames * h.frame_length
  283. cache = first_chunk[header_pos + 4:] # remove header from stream data.
  284. last_pos = 0
  285. pos = 0
  286. while True:
  287. pos_end = pos + send_size
  288. while len(cache) < pos_end:
  289. try:
  290. cache += chunks.next()
  291. except StopIteration:
  292. break
  293. send_buffer = cache[pos:pos_end]
  294. last_pos = min(pos_end, len(cache))
  295. if not send_buffer:
  296. # stream end
  297. send_buffer = None
  298. ms_pos = (yield send_buffer)
  299. if ms_pos is None:
  300. pos = last_pos
  301. elif ms_pos == 0:
  302. pos = 0
  303. else:
  304. # calculate new steam position from given MS value
  305. pos = int((ms_pos / ms_per_frame) * h.frame_length)
  306. def stream(self, track_id):
  307. """ Return coroutine with seeking capabilities: some_stream.send(30000) """
  308. track_data = self.get_track(track_id)
  309. track_cipher = self.get_track_cipher(track_data['SNG_ID'])
  310. track_url = self.get_track_url(track_data)
  311. return self._stream(track_cipher, track_url)