|
a/src/mediaserver/cdplugins/tidal/tidalapi/__init__.py |
|
b/src/mediaserver/cdplugins/tidal/tidalapi/__init__.py |
|
... |
|
... |
13 |
# GNU Lesser General Public License for more details.
|
13 |
# GNU Lesser General Public License for more details.
|
14 |
#
|
14 |
#
|
15 |
# You should have received a copy of the GNU Lesser General Public License
|
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/>.
|
16 |
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
17 |
|
17 |
|
18 |
from __future__ import unicode_literals
|
18 |
from __future__ import unicode_literals, print_function
|
19 |
|
19 |
|
|
|
20 |
import sys
|
|
|
21 |
import re
|
20 |
import datetime
|
22 |
import datetime
|
21 |
import json
|
23 |
import json
|
|
|
24 |
import random
|
22 |
import logging
|
25 |
import logging
|
23 |
import requests
|
26 |
import requests
|
|
|
27 |
from requests.packages import urllib3
|
24 |
from collections import namedtuple
|
28 |
from collections import namedtuple
|
|
|
29 |
from .models import SubscriptionType, Quality
|
25 |
from .models import Artist, Album, Track, Playlist, SearchResult, Category
|
30 |
from .models import Artist, Album, Track, Playlist, SearchResult, Category
|
26 |
try:
|
31 |
try:
|
27 |
from urlparse import urljoin
|
32 |
from urlparse import urljoin
|
28 |
except ImportError:
|
33 |
except ImportError:
|
29 |
from urllib.parse import urljoin
|
34 |
from urllib.parse import urljoin
|
30 |
|
35 |
|
|
|
36 |
class MLog(object):
|
|
|
37 |
def __init__(self):
|
|
|
38 |
self.f = sys.stderr
|
|
|
39 |
self.level = 1
|
|
|
40 |
def isEnabledFor(self, l):
|
|
|
41 |
return True
|
|
|
42 |
def debug(self, msg):
|
|
|
43 |
if self.level >= 3:
|
|
|
44 |
print("%s" % msg, file=self.f)
|
|
|
45 |
def info(self, msg):
|
|
|
46 |
if self.level >= 2:
|
|
|
47 |
print("%s" % msg, file=self.f)
|
|
|
48 |
def error(self, msg):
|
|
|
49 |
if self.level >= 1:
|
|
|
50 |
print("%s" % msg, file=self.f)
|
31 |
|
51 |
|
32 |
log = logging.getLogger(__name__)
|
52 |
#log = logging.getLogger(__name__)
|
|
|
53 |
log = MLog()
|
33 |
|
54 |
|
34 |
Api = namedtuple('API', ['location', 'token'])
|
55 |
# See https://github.com/arnesongit/python-tidal/ for token descs
|
35 |
|
|
|
36 |
|
|
|
37 |
class Quality(object):
|
|
|
38 |
lossless = 'LOSSLESS'
|
|
|
39 |
high = 'HIGH'
|
|
|
40 |
low = 'LOW'
|
|
|
41 |
|
|
|
42 |
|
|
|
43 |
class Config(object):
|
56 |
class Config(object):
|
44 |
def __init__(self, quality=Quality.high):
|
57 |
def __init__(self, quality=Quality.high):
|
45 |
self.quality = quality
|
58 |
self.quality = quality
|
46 |
self.api_location = 'https://api.tidalhifi.com/v1/'
|
59 |
self.api_location = 'https://api.tidal.com/v1/'
|
47 |
# For some reason, Kodi uses the following keys. The 2nd one results
|
60 |
self.api_token = 'kgsOOmYk3zShYrNP'
|
48 |
# in rtmp / flv stream containers which are ennoying to handle. No idea
|
61 |
self.preview_token = "8C7kRFdkaRp0dLBp" # Token for Preview Mode
|
49 |
# where it comes from (the other is from the older wimpy api), or why
|
|
|
50 |
# they do this.
|
|
|
51 |
self.api_token = 'P5Xbeo5LFvESeDy6' if self.quality == \
|
|
|
52 |
Quality.lossless else 'wdgaB1CilGA-S_s2'
|
|
|
53 |
|
62 |
|
54 |
|
63 |
|
55 |
class Session(object):
|
64 |
class Session(object):
|
56 |
|
65 |
|
57 |
def __init__(self, config=Config()):
|
66 |
def __init__(self, config=Config()):
|
|
|
67 |
""":type _config: :class:`Config`"""
|
|
|
68 |
self._config = config
|
58 |
self.session_id = None
|
69 |
self.session_id = None
|
59 |
self.country_code = None
|
|
|
60 |
self.user = None
|
70 |
self.user = None
|
61 |
self._config = config
|
71 |
self.country_code = 'US' # Enable Trial Mode
|
62 |
""":type _config: :class:`Config`"""
|
72 |
self.client_unique_key = None
|
|
|
73 |
urllib3.disable_warnings() # Disable OpenSSL Warnings in URLLIB3
|
63 |
|
74 |
|
|
|
75 |
def logout(self):
|
|
|
76 |
self.session_id = None
|
|
|
77 |
self.user = None
|
|
|
78 |
|
64 |
def load_session(self, session_id, country_code, user_id):
|
79 |
def load_session(self, session_id, country_code, user_id=None,
|
|
|
80 |
subscription_type=None, unique_key=None):
|
65 |
self.session_id = session_id
|
81 |
self.session_id = session_id
|
|
|
82 |
self.client_unique_key = unique_key
|
66 |
self.country_code = country_code
|
83 |
self.country_code = country_code
|
67 |
self.user = User(self, id=user_id)
|
84 |
if not self.country_code:
|
|
|
85 |
# Set Local Country Code to enable Trial Mode
|
|
|
86 |
self.country_code = self.local_country_code()
|
|
|
87 |
if user_id:
|
|
|
88 |
self.user = self.init_user(user_id=user_id,
|
|
|
89 |
subscription_type=subscription_type)
|
|
|
90 |
else:
|
|
|
91 |
self.user = None
|
68 |
|
92 |
|
|
|
93 |
def generate_client_unique_key(self):
|
|
|
94 |
return format(random.getrandbits(64), '02x')
|
|
|
95 |
|
69 |
def login(self, username, password):
|
96 |
def login(self, username, password, subscription_type=None):
|
|
|
97 |
self.logout()
|
|
|
98 |
if not username or not password:
|
|
|
99 |
return False
|
|
|
100 |
if not subscription_type:
|
|
|
101 |
# Set Subscription Type corresponding to the given playback quality
|
|
|
102 |
subscription_type = SubscriptionType.hifi if \
|
|
|
103 |
self._config.quality == Quality.lossless else \
|
|
|
104 |
SubscriptionType.premium
|
|
|
105 |
if not self.client_unique_key:
|
|
|
106 |
# Generate a random client key if no key is given
|
|
|
107 |
self.client_unique_key = self.generate_client_unique_key()
|
70 |
url = urljoin(self._config.api_location, 'login/username')
|
108 |
url = urljoin(self._config.api_location, 'login/username')
|
71 |
params = {'token': self._config.api_token}
|
109 |
headers = { "X-Tidal-Token": self._config.api_token }
|
72 |
payload = {
|
110 |
payload = {
|
73 |
'username': username,
|
111 |
'username': username,
|
74 |
'password': password,
|
112 |
'password': password,
|
|
|
113 |
'clientUniqueKey': self.client_unique_key
|
75 |
}
|
114 |
}
|
|
|
115 |
log.debug('Using Token "%s" with clientUniqueKey "%s"' %
|
|
|
116 |
(self._config.api_token, self.client_unique_key))
|
76 |
r = requests.post(url, data=payload, params=params)
|
117 |
r = requests.post(url, data=payload, headers=headers)
|
77 |
r.raise_for_status()
|
118 |
if not r.ok:
|
|
|
119 |
try:
|
|
|
120 |
msg = r.json().get('userMessage')
|
|
|
121 |
except:
|
|
|
122 |
msg = r.reason
|
|
|
123 |
log.error(msg)
|
|
|
124 |
else:
|
|
|
125 |
try:
|
78 |
body = r.json()
|
126 |
body = r.json()
|
79 |
self.session_id = body['sessionId']
|
127 |
self.session_id = body['sessionId']
|
80 |
self.country_code = body['countryCode']
|
128 |
self.country_code = body['countryCode']
|
81 |
self.user = User(self, id=body['userId'])
|
129 |
self.user = self.init_user(user_id=body['userId'],
|
82 |
return True
|
130 |
subscription_type=subscription_type)
|
|
|
131 |
except Exception as err:
|
|
|
132 |
log.error('Login failed. err %s %s' % (err, body))
|
|
|
133 |
self.logout()
|
83 |
|
134 |
|
|
|
135 |
return self.is_logged_in
|
|
|
136 |
|
|
|
137 |
def init_user(self, user_id, subscription_type):
|
|
|
138 |
return User(self, user_id=user_id, subscription_type=subscription_type)
|
|
|
139 |
|
|
|
140 |
def local_country_code(self):
|
|
|
141 |
url = urljoin(self._config.api_location, 'country/context')
|
|
|
142 |
headers = { "X-Tidal-Token": self._config.api_token}
|
|
|
143 |
r = requests.request('GET', url, params={'countryCode': 'WW'},
|
|
|
144 |
headers=headers)
|
|
|
145 |
if not r.ok:
|
|
|
146 |
return 'US'
|
|
|
147 |
return r.json().get('countryCode')
|
|
|
148 |
|
|
|
149 |
@property
|
|
|
150 |
def is_logged_in(self):
|
|
|
151 |
return True if self.session_id and self.country_code and self.user \
|
|
|
152 |
else False
|
|
|
153 |
|
84 |
def check_login(self):
|
154 |
def check_login(self):
|
85 |
""" Returns true if current session is valid, false otherwise. """
|
155 |
""" Returns true if current session is valid, false otherwise. """
|
86 |
if self.user is None or not self.user.id or not self.session_id:
|
156 |
if not self.is_logged_in:
|
87 |
return False
|
157 |
return False
|
88 |
url = urljoin(self._config.api_location, 'users/%s/subscription' % self.user.id)
|
158 |
self.user.subscription = self.get_user_subscription(self.user.id)
|
89 |
return requests.get(url, params={'sessionId': self.session_id}).ok
|
159 |
return True if self.user.subscription != None else False
|
90 |
|
160 |
|
91 |
def request(self, method, path, params=None, data=None):
|
161 |
def request(self, method, path, params=None, data=None, headers=None):
|
|
|
162 |
request_headers = {}
|
92 |
request_params = {
|
163 |
request_params = {
|
93 |
'sessionId': self.session_id,
|
164 |
'sessionId': self.session_id,
|
94 |
'countryCode': self.country_code,
|
165 |
'countryCode': self.country_code,
|
95 |
'limit': '999',
|
166 |
'limit': '999',
|
96 |
}
|
167 |
}
|
|
|
168 |
if headers:
|
|
|
169 |
request_headers.update(headers)
|
97 |
if params:
|
170 |
if params:
|
98 |
request_params.update(params)
|
171 |
request_params.update(params)
|
99 |
url = urljoin(self._config.api_location, path)
|
172 |
url = urljoin(self._config.api_location, path)
|
|
|
173 |
if self.is_logged_in:
|
|
|
174 |
# Request with API Session if SessionId is not given in headers parameter
|
|
|
175 |
if not 'X-Tidal-SessionId' in request_headers:
|
|
|
176 |
request_headers.update({'X-Tidal-SessionId': self.session_id})
|
|
|
177 |
else:
|
|
|
178 |
# Request with Preview-Token. Remove SessionId if given via headers parameter
|
|
|
179 |
request_headers.pop('X-Tidal-SessionId', None)
|
|
|
180 |
request_params.update({'token': self._config.preview_token})
|
100 |
r = requests.request(method, url, params=request_params, data=data)
|
181 |
r = requests.request(method, url, params=request_params, data=data, headers=request_headers)
|
101 |
log.debug("request: %s" % r.request.url)
|
182 |
log.debug("%s %s" % (method, r.request.url))
|
|
|
183 |
if not r.ok:
|
|
|
184 |
log.error(r.url)
|
|
|
185 |
try:
|
|
|
186 |
log.error(r.json().get('userMessage'))
|
|
|
187 |
except:
|
|
|
188 |
log.error(r.reason)
|
102 |
r.raise_for_status()
|
189 |
r.raise_for_status()
|
103 |
if r.content:
|
190 |
if r.content and log.isEnabledFor(logging.INFO):
|
104 |
log.debug("response: %s" % json.dumps(r.json(), indent=4))
|
191 |
log.info("response: %s" % json.dumps(r.json(), indent=4))
|
105 |
return r
|
192 |
return r
|
106 |
|
193 |
|
107 |
def get_user(self, user_id):
|
194 |
def get_user(self, user_id):
|
108 |
return self._map_request('users/%s' % user_id, ret='user')
|
195 |
return self._map_request('users/%s' % user_id, ret='user')
|
|
|
196 |
|
|
|
197 |
def get_user_subscription(self, user_id):
|
|
|
198 |
return self._map_request('users/%s/subscription' % user_id, ret='subscription')
|
109 |
|
199 |
|
110 |
def get_user_playlists(self, user_id):
|
200 |
def get_user_playlists(self, user_id):
|
111 |
return self._map_request('users/%s/playlists' % user_id, ret='playlists')
|
201 |
return self._map_request('users/%s/playlists' % user_id, ret='playlists')
|
112 |
|
202 |
|
113 |
def get_playlist(self, playlist_id):
|
203 |
def get_playlist(self, playlist_id):
|
|
... |
|
... |
337 |
|
427 |
|
338 |
class User(object):
|
428 |
class User(object):
|
339 |
|
429 |
|
340 |
favorites = None
|
430 |
favorites = None
|
341 |
|
431 |
|
342 |
def __init__(self, session, id):
|
432 |
def __init__(self, session, user_id, subscription_type=SubscriptionType.hifi):
|
343 |
"""
|
|
|
344 |
:type session: :class:`Session`
|
|
|
345 |
:param id: The user ID
|
|
|
346 |
"""
|
|
|
347 |
self._session = session
|
433 |
self._session = session
|
348 |
self.id = id
|
434 |
self.id = user_id
|
349 |
self.favorites = Favorites(session, self.id)
|
435 |
self.favorites = Favorites(session, self.id)
|
350 |
|
436 |
|
351 |
def playlists(self):
|
437 |
def playlists(self):
|
352 |
return self._session.get_user_playlists(self.id)
|
438 |
return self._session.get_user_playlists(self.id)
|