|
a |
|
b/src/cdplugins/tidal/tidalapi/__init__.py |
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
#
|
|
|
3 |
# Copyright (C) 2014 Thomas Amland
|
|
|
4 |
#
|
|
|
5 |
# This program is free software: you can redistribute it and/or modify
|
|
|
6 |
# it under the terms of the GNU Lesser General Public License as published by
|
|
|
7 |
# the Free Software Foundation, either version 3 of the License, or
|
|
|
8 |
# (at your option) any later version.
|
|
|
9 |
#
|
|
|
10 |
# This program is distributed in the hope that it will be useful,
|
|
|
11 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
12 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
13 |
# GNU Lesser General Public License for more details.
|
|
|
14 |
#
|
|
|
15 |
# You should have received a copy of the GNU Lesser General Public License
|
|
|
16 |
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
17 |
|
|
|
18 |
from __future__ import unicode_literals
|
|
|
19 |
|
|
|
20 |
import datetime
|
|
|
21 |
import json
|
|
|
22 |
import logging
|
|
|
23 |
import requests
|
|
|
24 |
from collections import namedtuple
|
|
|
25 |
from .models import Artist, Album, Track, Playlist, SearchResult, Category, Role
|
|
|
26 |
try:
|
|
|
27 |
from urlparse import urljoin
|
|
|
28 |
except ImportError:
|
|
|
29 |
from urllib.parse import urljoin
|
|
|
30 |
|
|
|
31 |
|
|
|
32 |
log = logging.getLogger(__name__)
|
|
|
33 |
|
|
|
34 |
Api = namedtuple('API', ['location', 'token'])
|
|
|
35 |
|
|
|
36 |
|
|
|
37 |
class Quality(object):
|
|
|
38 |
lossless = 'LOSSLESS'
|
|
|
39 |
high = 'HIGH'
|
|
|
40 |
low = 'LOW'
|
|
|
41 |
|
|
|
42 |
|
|
|
43 |
class Config(object):
|
|
|
44 |
def __init__(self, quality=Quality.high):
|
|
|
45 |
self.quality = quality
|
|
|
46 |
self.api_location = 'https://api.tidalhifi.com/v1/'
|
|
|
47 |
self.api_token = 'P5Xbeo5LFvESeDy6' if self.quality == \
|
|
|
48 |
Quality.lossless else 'wdgaB1CilGA-S_s2',
|
|
|
49 |
|
|
|
50 |
|
|
|
51 |
class Session(object):
|
|
|
52 |
|
|
|
53 |
def __init__(self, config=Config()):
|
|
|
54 |
self.session_id = None
|
|
|
55 |
self.country_code = None
|
|
|
56 |
self.user = None
|
|
|
57 |
self._config = config
|
|
|
58 |
""":type _config: :class:`Config`"""
|
|
|
59 |
|
|
|
60 |
def load_session(self, session_id, country_code, user_id):
|
|
|
61 |
self.session_id = session_id
|
|
|
62 |
self.country_code = country_code
|
|
|
63 |
self.user = User(self, id=user_id)
|
|
|
64 |
|
|
|
65 |
def login(self, username, password):
|
|
|
66 |
url = urljoin(self._config.api_location, 'login/username')
|
|
|
67 |
params = {'token': self._config.api_token}
|
|
|
68 |
payload = {
|
|
|
69 |
'username': username,
|
|
|
70 |
'password': password,
|
|
|
71 |
}
|
|
|
72 |
r = requests.post(url, data=payload, params=params)
|
|
|
73 |
r.raise_for_status()
|
|
|
74 |
body = r.json()
|
|
|
75 |
self.session_id = body['sessionId']
|
|
|
76 |
self.country_code = body['countryCode']
|
|
|
77 |
self.user = User(self, id=body['userId'])
|
|
|
78 |
return True
|
|
|
79 |
|
|
|
80 |
def check_login(self):
|
|
|
81 |
""" Returns true if current session is valid, false otherwise. """
|
|
|
82 |
if self.user is None or not self.user.id or not self.session_id:
|
|
|
83 |
return False
|
|
|
84 |
url = urljoin(self._config.api_location, 'users/%s/subscription' % self.user.id)
|
|
|
85 |
return requests.get(url, params={'sessionId': self.session_id}).ok
|
|
|
86 |
|
|
|
87 |
def request(self, method, path, params=None, data=None):
|
|
|
88 |
request_params = {
|
|
|
89 |
'sessionId': self.session_id,
|
|
|
90 |
'countryCode': self.country_code,
|
|
|
91 |
'limit': '999',
|
|
|
92 |
}
|
|
|
93 |
if params:
|
|
|
94 |
request_params.update(params)
|
|
|
95 |
url = urljoin(self._config.api_location, path)
|
|
|
96 |
r = requests.request(method, url, params=request_params, data=data)
|
|
|
97 |
log.debug("request: %s" % r.request.url)
|
|
|
98 |
r.raise_for_status()
|
|
|
99 |
if r.content:
|
|
|
100 |
log.debug("response: %s" % json.dumps(r.json(), indent=4))
|
|
|
101 |
return r
|
|
|
102 |
|
|
|
103 |
def get_user(self, user_id):
|
|
|
104 |
return self._map_request('users/%s' % user_id, ret='user')
|
|
|
105 |
|
|
|
106 |
def get_user_playlists(self, user_id):
|
|
|
107 |
return self._map_request('users/%s/playlists' % user_id, ret='playlists')
|
|
|
108 |
|
|
|
109 |
def get_playlist(self, playlist_id):
|
|
|
110 |
return self._map_request('playlists/%s' % playlist_id, ret='playlist')
|
|
|
111 |
|
|
|
112 |
def get_playlist_tracks(self, playlist_id):
|
|
|
113 |
return self._map_request('playlists/%s/tracks' % playlist_id, ret='tracks')
|
|
|
114 |
|
|
|
115 |
def get_album(self, album_id):
|
|
|
116 |
return self._map_request('albums/%s' % album_id, ret='album')
|
|
|
117 |
|
|
|
118 |
def get_album_tracks(self, album_id):
|
|
|
119 |
return self._map_request('albums/%s/tracks' % album_id, ret='tracks')
|
|
|
120 |
|
|
|
121 |
def get_artist(self, artist_id):
|
|
|
122 |
return self._map_request('artists/%s' % artist_id, ret='artist')
|
|
|
123 |
|
|
|
124 |
def get_artist_albums(self, artist_id):
|
|
|
125 |
return self._map_request('artists/%s/albums' % artist_id, ret='albums')
|
|
|
126 |
|
|
|
127 |
def get_artist_albums_ep_singles(self, artist_id):
|
|
|
128 |
params = {'filter': 'EPSANDSINGLES'}
|
|
|
129 |
return self._map_request('artists/%s/albums' % artist_id, params, ret='albums')
|
|
|
130 |
|
|
|
131 |
def get_artist_albums_other(self, artist_id):
|
|
|
132 |
params = {'filter': 'COMPILATIONS'}
|
|
|
133 |
return self._map_request('artists/%s/albums' % artist_id, params, ret='albums')
|
|
|
134 |
|
|
|
135 |
def get_artist_top_tracks(self, artist_id):
|
|
|
136 |
return self._map_request('artists/%s/toptracks' % artist_id, ret='tracks')
|
|
|
137 |
|
|
|
138 |
def get_artist_bio(self, artist_id):
|
|
|
139 |
return self.request('GET', 'artists/%s/bio' % artist_id).json()['text']
|
|
|
140 |
|
|
|
141 |
def get_artist_similar(self, artist_id):
|
|
|
142 |
return self._map_request('artists/%s/similar' % artist_id, ret='artists')
|
|
|
143 |
|
|
|
144 |
def get_artist_radio(self, artist_id):
|
|
|
145 |
return self._map_request('artists/%s/radio' % artist_id, params={'limit': 100}, ret='tracks')
|
|
|
146 |
|
|
|
147 |
def get_featured(self):
|
|
|
148 |
items = self.request('GET', 'promotions').json()['items']
|
|
|
149 |
return [_parse_featured_playlist(item) for item in items if item['type'] == 'PLAYLIST']
|
|
|
150 |
|
|
|
151 |
def get_featured_items(self, content_type, group):
|
|
|
152 |
return self._map_request('/'.join(['featured', group, content_type]), ret=content_type)
|
|
|
153 |
|
|
|
154 |
def get_moods(self):
|
|
|
155 |
return map(_parse_moods, self.request('GET', 'moods').json())
|
|
|
156 |
|
|
|
157 |
def get_mood_playlists(self, mood_id):
|
|
|
158 |
return self._map_request('/'.join(['moods', mood_id, 'playlists']), ret='playlists')
|
|
|
159 |
|
|
|
160 |
def get_genres(self):
|
|
|
161 |
return map(_parse_genres, self.request('GET', 'genres').json())
|
|
|
162 |
|
|
|
163 |
def get_genre_items(self, genre_id, content_type):
|
|
|
164 |
return self._map_request('/'.join(['genres', genre_id, content_type]), ret=content_type)
|
|
|
165 |
|
|
|
166 |
def get_track_radio(self, track_id):
|
|
|
167 |
return self._map_request('tracks/%s/radio' % track_id, params={'limit': 100}, ret='tracks')
|
|
|
168 |
|
|
|
169 |
def get_track(self, track_id):
|
|
|
170 |
return self._map_request('tracks/%s' % track_id, ret='track')
|
|
|
171 |
|
|
|
172 |
def _map_request(self, url, params=None, ret=None):
|
|
|
173 |
json_obj = self.request('GET', url, params).json()
|
|
|
174 |
parse = None
|
|
|
175 |
if ret.startswith('artist'):
|
|
|
176 |
parse = _parse_artist
|
|
|
177 |
elif ret.startswith('album'):
|
|
|
178 |
parse = _parse_album
|
|
|
179 |
elif ret.startswith('track'):
|
|
|
180 |
parse = _parse_track
|
|
|
181 |
elif ret.startswith('user'):
|
|
|
182 |
raise NotImplementedError()
|
|
|
183 |
elif ret.startswith('playlist'):
|
|
|
184 |
parse = _parse_playlist
|
|
|
185 |
|
|
|
186 |
items = json_obj.get('items')
|
|
|
187 |
if items is None:
|
|
|
188 |
return parse(json_obj)
|
|
|
189 |
elif len(items) > 0 and 'item' in items[0]:
|
|
|
190 |
return list(map(parse, [item['item'] for item in items]))
|
|
|
191 |
else:
|
|
|
192 |
return list(map(parse, items))
|
|
|
193 |
|
|
|
194 |
def get_media_url(self, track_id):
|
|
|
195 |
params = {'soundQuality': self._config.quality}
|
|
|
196 |
r = self.request('GET', 'tracks/%s/streamUrl' % track_id, params)
|
|
|
197 |
return r.json()['url']
|
|
|
198 |
|
|
|
199 |
def search(self, field, value):
|
|
|
200 |
params = {
|
|
|
201 |
'query': value,
|
|
|
202 |
'limit': 50,
|
|
|
203 |
}
|
|
|
204 |
if field not in ['artist', 'album', 'playlist', 'track']:
|
|
|
205 |
raise ValueError('Unknown field \'%s\'' % field)
|
|
|
206 |
|
|
|
207 |
ret_type = field + 's'
|
|
|
208 |
url = 'search/' + field + 's'
|
|
|
209 |
result = self._map_request(url, params, ret=ret_type)
|
|
|
210 |
return SearchResult(**{ret_type: result})
|
|
|
211 |
|
|
|
212 |
|
|
|
213 |
def _parse_artist(json_obj):
|
|
|
214 |
return Artist(id=json_obj['id'], name=json_obj['name'], role=Role(json_obj['type']))
|
|
|
215 |
|
|
|
216 |
|
|
|
217 |
def _parse_artists(json_obj):
|
|
|
218 |
return list(map(_parse_artist, json_obj))
|
|
|
219 |
|
|
|
220 |
|
|
|
221 |
def _parse_album(json_obj, artist=None, artists=None):
|
|
|
222 |
if artist is None:
|
|
|
223 |
artist = _parse_artist(json_obj['artist'])
|
|
|
224 |
if artists is None:
|
|
|
225 |
artists = _parse_artists(json_obj['artists'])
|
|
|
226 |
kwargs = {
|
|
|
227 |
'id': json_obj['id'],
|
|
|
228 |
'name': json_obj['title'],
|
|
|
229 |
'num_tracks': json_obj.get('numberOfTracks'),
|
|
|
230 |
'duration': json_obj.get('duration'),
|
|
|
231 |
'artist': artist,
|
|
|
232 |
'artists': artists,
|
|
|
233 |
}
|
|
|
234 |
if 'releaseDate' in json_obj:
|
|
|
235 |
try:
|
|
|
236 |
kwargs['release_date'] = datetime.datetime(*map(int, json_obj['releaseDate'].split('-')))
|
|
|
237 |
except ValueError:
|
|
|
238 |
pass
|
|
|
239 |
return Album(**kwargs)
|
|
|
240 |
|
|
|
241 |
|
|
|
242 |
def _parse_featured_playlist(json_obj):
|
|
|
243 |
kwargs = {
|
|
|
244 |
'id': json_obj['artifactId'],
|
|
|
245 |
'name': json_obj['header'],
|
|
|
246 |
'description': json_obj['text'],
|
|
|
247 |
}
|
|
|
248 |
return Playlist(**kwargs)
|
|
|
249 |
|
|
|
250 |
|
|
|
251 |
def _parse_playlist(json_obj):
|
|
|
252 |
kwargs = {
|
|
|
253 |
'id': json_obj['uuid'],
|
|
|
254 |
'name': json_obj['title'],
|
|
|
255 |
'description': json_obj['description'],
|
|
|
256 |
'num_tracks': int(json_obj['numberOfTracks']),
|
|
|
257 |
'duration': int(json_obj['duration']),
|
|
|
258 |
'is_public': json_obj['publicPlaylist'],
|
|
|
259 |
#TODO 'creator': _parse_user(json_obj['creator']),
|
|
|
260 |
}
|
|
|
261 |
return Playlist(**kwargs)
|
|
|
262 |
|
|
|
263 |
|
|
|
264 |
def _parse_track(json_obj):
|
|
|
265 |
artist = _parse_artist(json_obj['artist'])
|
|
|
266 |
artists = _parse_artists(json_obj['artists'])
|
|
|
267 |
album = _parse_album(json_obj['album'], artist, artists)
|
|
|
268 |
kwargs = {
|
|
|
269 |
'id': json_obj['id'],
|
|
|
270 |
'name': json_obj['title'],
|
|
|
271 |
'duration': json_obj['duration'],
|
|
|
272 |
'track_num': json_obj['trackNumber'],
|
|
|
273 |
'disc_num': json_obj['volumeNumber'],
|
|
|
274 |
'popularity': json_obj['popularity'],
|
|
|
275 |
'artist': artist,
|
|
|
276 |
'artists': artists,
|
|
|
277 |
'album': album,
|
|
|
278 |
'available': bool(json_obj['streamReady']),
|
|
|
279 |
}
|
|
|
280 |
return Track(**kwargs)
|
|
|
281 |
|
|
|
282 |
|
|
|
283 |
def _parse_genres(json_obj):
|
|
|
284 |
image = "http://resources.wimpmusic.com/images/%s/460x306.jpg" \
|
|
|
285 |
% json_obj['image'].replace('-', '/')
|
|
|
286 |
return Category(id=json_obj['path'], name=json_obj['name'], image=image)
|
|
|
287 |
|
|
|
288 |
|
|
|
289 |
def _parse_moods(json_obj):
|
|
|
290 |
image = "http://resources.wimpmusic.com/images/%s/342x342.jpg" \
|
|
|
291 |
% json_obj['image'].replace('-', '/')
|
|
|
292 |
return Category(id=json_obj['path'], name=json_obj['name'], image=image)
|
|
|
293 |
|
|
|
294 |
|
|
|
295 |
class Favorites(object):
|
|
|
296 |
|
|
|
297 |
def __init__(self, session, user_id):
|
|
|
298 |
self._session = session
|
|
|
299 |
self._base_url = 'users/%s/favorites' % user_id
|
|
|
300 |
|
|
|
301 |
def add_artist(self, artist_id):
|
|
|
302 |
return self._session.request('POST', self._base_url + '/artists', data={'artistId': artist_id}).ok
|
|
|
303 |
|
|
|
304 |
def add_album(self, album_id):
|
|
|
305 |
return self._session.request('POST', self._base_url + '/albums', data={'albumId': album_id}).ok
|
|
|
306 |
|
|
|
307 |
def add_track(self, track_id):
|
|
|
308 |
return self._session.request('POST', self._base_url + '/tracks', data={'trackId': track_id}).ok
|
|
|
309 |
|
|
|
310 |
def remove_artist(self, artist_id):
|
|
|
311 |
return self._session.request('DELETE', self._base_url + '/artists/%s' % artist_id).ok
|
|
|
312 |
|
|
|
313 |
def remove_album(self, album_id):
|
|
|
314 |
return self._session.request('DELETE', self._base_url + '/albums/%s' % album_id).ok
|
|
|
315 |
|
|
|
316 |
def remove_track(self, track_id):
|
|
|
317 |
return self._session.request('DELETE', self._base_url + '/tracks/%s' % track_id).ok
|
|
|
318 |
|
|
|
319 |
def artists(self):
|
|
|
320 |
return self._session._map_request(self._base_url + '/artists', ret='artists')
|
|
|
321 |
|
|
|
322 |
def albums(self):
|
|
|
323 |
return self._session._map_request(self._base_url + '/albums', ret='albums')
|
|
|
324 |
|
|
|
325 |
def playlists(self):
|
|
|
326 |
return self._session._map_request(self._base_url + '/playlists', ret='playlists')
|
|
|
327 |
|
|
|
328 |
def tracks(self):
|
|
|
329 |
r = self._session.request('GET', self._base_url + '/tracks')
|
|
|
330 |
return [_parse_track(item['item']) for item in r.json()['items']]
|
|
|
331 |
|
|
|
332 |
|
|
|
333 |
class User(object):
|
|
|
334 |
|
|
|
335 |
favorites = None
|
|
|
336 |
|
|
|
337 |
def __init__(self, session, id):
|
|
|
338 |
"""
|
|
|
339 |
:type session: :class:`Session`
|
|
|
340 |
:param id: The user ID
|
|
|
341 |
"""
|
|
|
342 |
self._session = session
|
|
|
343 |
self.id = id
|
|
|
344 |
self.favorites = Favorites(session, self.id)
|
|
|
345 |
|
|
|
346 |
def playlists(self):
|
|
|
347 |
return self._session.get_user_playlists(self.id)
|