spotifywebapiPython 1.0.76__py3-none-any.whl → 1.0.78__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of spotifywebapiPython might be problematic. Click here for more details.
- {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/METADATA +1 -1
- {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/RECORD +23 -23
- spotifywebapipython/const.py +1 -1
- spotifywebapipython/docs/__init__.html +19 -2
- spotifywebapipython/docs/index.html +2 -2
- spotifywebapipython/docs/search.js +1 -1
- spotifywebapipython/docs/spotifywebapiPython/const.html +3 -3
- spotifywebapipython/docs/spotifywebapiPython/saappmessages.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/sautils.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifyapierror.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifyapimessage.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifyauthtoken.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifyclient.html +116 -59
- spotifywebapipython/docs/spotifywebapiPython/spotifydiscovery.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifywebapiauthenticationerror.html +2 -2
- spotifywebapipython/docs/spotifywebapiPython/spotifywebapierror.html +2 -2
- spotifywebapipython/oauthcli/authclient.py +10 -11
- spotifywebapipython/spotifyclient.py +290 -154
- spotifywebapipython/zeroconfapi/blobbuilder.py +7 -12
- spotifywebapipython/zeroconfapi/zeroconfconnect.py +259 -44
- {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/LICENSE +0 -0
- {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/WHEEL +0 -0
- {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/top_level.txt +0 -0
|
@@ -131,19 +131,14 @@ class BlobBuilder:
|
|
|
131
131
|
|
|
132
132
|
# create the blob data that will be sent to the device.
|
|
133
133
|
blob = bytearray()
|
|
134
|
-
# 'I'
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
# '
|
|
139
|
-
|
|
140
|
-
# auth_type
|
|
141
|
-
write_int(self.credentials.auth_type, blob)
|
|
142
|
-
# 'Q'
|
|
143
|
-
write_int(0x51, blob)
|
|
144
|
-
# password
|
|
145
|
-
write_bytes(self.credentials.password, blob)
|
|
134
|
+
write_int(0x49, blob) # 'I' (eye-catcher)
|
|
135
|
+
write_bytes(self.credentials.username, blob) # username data (length + value)
|
|
136
|
+
write_int(0x50, blob) # 'P' (eye-catcher)
|
|
137
|
+
write_int(self.credentials.auth_type, blob) # auth_type
|
|
138
|
+
write_int(0x51, blob) # 'Q' (eye-catcher)
|
|
139
|
+
write_bytes(self.credentials.password, blob) # password data (length + value)
|
|
146
140
|
_logsi.LogBinary(SILevel.Verbose, "Blob Data, Unencrypted Bytes", blob)
|
|
141
|
+
|
|
147
142
|
# padding
|
|
148
143
|
n_zeros = BlobBuilder.AES_BLOCK_SIZE - (len(blob) % BlobBuilder.AES_BLOCK_SIZE) - 1
|
|
149
144
|
blob.extend([0] * n_zeros)
|
|
@@ -13,9 +13,14 @@ from .zeroconfresponse import ZeroconfResponse
|
|
|
13
13
|
from .zeroconfgetinfo import ZeroconfGetInfo
|
|
14
14
|
from ..saappmessages import SAAppMessages
|
|
15
15
|
from ..sautils import export, validateDelay
|
|
16
|
+
from ..spotifyauthtoken import SpotifyAuthToken
|
|
16
17
|
from ..spotifyapierror import SpotifyApiError
|
|
17
18
|
from ..spotifywebapierror import SpotifyWebApiError
|
|
19
|
+
from ..oauthcli.authclient import AuthClient
|
|
18
20
|
from ..const import (
|
|
21
|
+
SPOTIFY_API_AUTHORIZE_URL,
|
|
22
|
+
SPOTIFY_API_TOKEN_URL,
|
|
23
|
+
TRACE_METHOD_RESULT,
|
|
19
24
|
TRACE_MSG_DELAY_DEVICE,
|
|
20
25
|
)
|
|
21
26
|
from ..sautils import passwordMaskString
|
|
@@ -53,7 +58,8 @@ class ZeroconfConnect:
|
|
|
53
58
|
hostIpPort:int,
|
|
54
59
|
cpath:str,
|
|
55
60
|
version:str=None,
|
|
56
|
-
useSSL:bool=False
|
|
61
|
+
useSSL:bool=False,
|
|
62
|
+
tokenStorageDir:str=None,
|
|
57
63
|
) -> None:
|
|
58
64
|
"""
|
|
59
65
|
Initializes a new instance of the class.
|
|
@@ -74,6 +80,11 @@ class ZeroconfConnect:
|
|
|
74
80
|
True if the host device utilizes HTTPS Secure Sockets Layer (SSL) support;
|
|
75
81
|
otherwise, False to utilize HTTP.
|
|
76
82
|
Default is False (HTTP).
|
|
83
|
+
tokenStorageDir (str):
|
|
84
|
+
The directory path that will contain the `tokens.json` cache file.
|
|
85
|
+
This is used for Spotify Connect devices that utilize the `authorization_code` token type.
|
|
86
|
+
A null value will default to the platform specific storage location:
|
|
87
|
+
Example for Windows OS = `C:\ProgramData\SpotifyWebApiPython`
|
|
77
88
|
"""
|
|
78
89
|
# validations.
|
|
79
90
|
if (useSSL is None) or (not isinstance(useSSL,bool)):
|
|
@@ -85,6 +96,7 @@ class ZeroconfConnect:
|
|
|
85
96
|
self._CPath:str = cpath
|
|
86
97
|
self._HostIpPort:int = hostIpPort
|
|
87
98
|
self._HostIpAddress:str = hostIpAddress
|
|
99
|
+
self._TokenStorageDir:str = tokenStorageDir
|
|
88
100
|
self._UseSSL:bool = useSSL
|
|
89
101
|
self._Version:str = version
|
|
90
102
|
|
|
@@ -118,6 +130,14 @@ class ZeroconfConnect:
|
|
|
118
130
|
return self._HostIpPort
|
|
119
131
|
|
|
120
132
|
|
|
133
|
+
@property
|
|
134
|
+
def TokenStorageDir(self) -> str:
|
|
135
|
+
"""
|
|
136
|
+
The directory path that will contain the authorization token cache file.
|
|
137
|
+
"""
|
|
138
|
+
return self._TokenStorageDir
|
|
139
|
+
|
|
140
|
+
|
|
121
141
|
@property
|
|
122
142
|
def Uri(self) -> str:
|
|
123
143
|
"""
|
|
@@ -145,7 +165,7 @@ class ZeroconfConnect:
|
|
|
145
165
|
return self._Version or ''
|
|
146
166
|
|
|
147
167
|
|
|
148
|
-
def _CheckResponseForErrors(self, response:Response, methodName:str, endpoint:str) -> None:
|
|
168
|
+
def _CheckResponseForErrors(self, response:Response, methodName:str, endpoint:str, tracePrefix:str='ZeroconfConnect') -> None:
|
|
149
169
|
"""
|
|
150
170
|
Checks the Spotify Zeroconf API response for errors.
|
|
151
171
|
|
|
@@ -162,6 +182,9 @@ class ZeroconfConnect:
|
|
|
162
182
|
method name that made the request (for trace purposes).
|
|
163
183
|
endpoint (str):
|
|
164
184
|
Endpoint that was requested (for trace purposes).
|
|
185
|
+
tracePrefix (str):
|
|
186
|
+
Prefix text used in trace log messages.
|
|
187
|
+
Default is "ZeroconfConnect".
|
|
165
188
|
|
|
166
189
|
Raises:
|
|
167
190
|
SpotifyZeroconfApiError:
|
|
@@ -174,13 +197,13 @@ class ZeroconfConnect:
|
|
|
174
197
|
|
|
175
198
|
# trace.
|
|
176
199
|
if _logsi.IsOn(SILevel.Debug):
|
|
177
|
-
_logsi.LogObject(SILevel.Debug, '
|
|
200
|
+
_logsi.LogObject(SILevel.Debug, '%s http response object - type="%s", module="%s"' % (tracePrefix, type(response).__name__, type(response).__module__), response)
|
|
178
201
|
|
|
179
202
|
# trace.
|
|
180
203
|
if _logsi.IsOn(SILevel.Debug):
|
|
181
|
-
_logsi.LogObject(SILevel.Debug, "
|
|
204
|
+
_logsi.LogObject(SILevel.Debug, "%s http response [%s-%s]: '%s' (response)" % (tracePrefix, response.status_code, response.reason, endpoint), response)
|
|
182
205
|
if (response.headers):
|
|
183
|
-
_logsi.LogCollection(SILevel.Debug, "
|
|
206
|
+
_logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s]: '%s' (headers)" % (tracePrefix, response.status_code, response.reason, endpoint), response.headers.items())
|
|
184
207
|
|
|
185
208
|
# do we have response data?
|
|
186
209
|
if (response.content is not None) and (len(response.content) > 0):
|
|
@@ -193,11 +216,11 @@ class ZeroconfConnect:
|
|
|
193
216
|
|
|
194
217
|
if _logsi.IsOn(SILevel.Verbose):
|
|
195
218
|
if isinstance(responseData, dict):
|
|
196
|
-
_logsi.LogDictionary(SILevel.Verbose, "
|
|
219
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json dict)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData, prettyPrint=True)
|
|
197
220
|
elif isinstance(responseData, list):
|
|
198
|
-
_logsi.LogArray(SILevel.Verbose, "
|
|
221
|
+
_logsi.LogArray(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json array)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
|
|
199
222
|
else:
|
|
200
|
-
_logsi.LogObject(SILevel.Verbose, "
|
|
223
|
+
_logsi.LogObject(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json object)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
|
|
201
224
|
|
|
202
225
|
else:
|
|
203
226
|
|
|
@@ -209,12 +232,12 @@ class ZeroconfConnect:
|
|
|
209
232
|
except SpotifyApiError: raise # pass handled exceptions on thru
|
|
210
233
|
except Exception as ex:
|
|
211
234
|
|
|
212
|
-
_logsi.LogException("
|
|
235
|
+
_logsi.LogException("{tracePrefix} http response could not be converted to JSON and will be converted to utf-8.\nConversion exception returned was:\n{ex}".format(tracePrefix=tracePrefix, ex=str(ex)), ex, logToSystemLogger=False)
|
|
213
236
|
|
|
214
237
|
# if json conversion failed, then convert to utf-8 response.
|
|
215
238
|
if response.content is not None:
|
|
216
239
|
responseUTF8 = response.content.decode('utf-8')
|
|
217
|
-
_logsi.LogText(SILevel.Error, "
|
|
240
|
+
_logsi.LogText(SILevel.Error, "%s http response [%s-%s]: '%s' (utf-8)" % (tracePrefix, response.status_code, response.reason, endpoint), responseUTF8)
|
|
218
241
|
|
|
219
242
|
# at this point we don't know what Spotify Web Api returned!
|
|
220
243
|
# it's been found that some devices (Sonos, etc) do not return a proper JSON response.
|
|
@@ -465,29 +488,29 @@ class ZeroconfConnect:
|
|
|
465
488
|
apiMethodParms.AppendKeyValue("info.PublicKey", info.PublicKey)
|
|
466
489
|
apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
|
|
467
490
|
apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
|
|
491
|
+
apiMethodParms.AppendKeyValue("info.TokenType", info.TokenType)
|
|
468
492
|
_logsi.LogMethodParmList(SILevel.Verbose, "Issuing Spotify Connect Zeroconf addUser request (ip=%s)" % self._HostIpAddress, apiMethodParms)
|
|
469
493
|
|
|
470
|
-
# formulate the blob.
|
|
471
|
-
credentials:Credentials = Credentials(username, password, AuthenticationTypes.USER_PASS)
|
|
472
|
-
builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
|
|
473
|
-
blob = builder.build()
|
|
474
|
-
|
|
475
494
|
# set request endpoint.
|
|
476
495
|
endpoint:str = self.GetEndpoint('addUser')
|
|
477
496
|
|
|
478
497
|
# set request headers.
|
|
479
498
|
reqHeaders:dict = {
|
|
480
499
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
481
|
-
'Connection': 'close'
|
|
500
|
+
#'Connection': 'close',
|
|
501
|
+
'Connection': 'keep-alive',
|
|
502
|
+
'Keep-Alive': '0',
|
|
503
|
+
'Accept-Encoding': 'gzip',
|
|
482
504
|
}
|
|
483
|
-
|
|
505
|
+
|
|
506
|
+
# create credentials and builder objects.
|
|
507
|
+
credentials:Credentials = Credentials(username, password, AuthenticationTypes.USER_PASS)
|
|
508
|
+
builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
|
|
484
509
|
|
|
485
510
|
# set defaults used by most manufacturers - we will tailor these in the
|
|
486
|
-
# logic below for specific manufacturers / devices as required
|
|
487
|
-
# - clientKey should be the getInfo response PublicKey (base64 encoded).
|
|
511
|
+
# logic below for specific manufacturers / devices as required.
|
|
488
512
|
# - exclude origin device information from the addUser request.
|
|
489
513
|
# - tokenType should be set to 'default'.
|
|
490
|
-
clientKey:str = builder.dh_keys.PublicKeyBase64String
|
|
491
514
|
includeOriginDeviceInfo:bool = False
|
|
492
515
|
tokenType:str = 'default'
|
|
493
516
|
|
|
@@ -496,33 +519,32 @@ class ZeroconfConnect:
|
|
|
496
519
|
includeOriginDeviceInfo = True
|
|
497
520
|
|
|
498
521
|
# special processing for tokenType "authorization_code":
|
|
499
|
-
# - include origin device information in the addUser request.
|
|
500
|
-
# - set the tokenType to 'authorization_code'.
|
|
501
|
-
# - set the clientKey value to null, as it is contained in the token.
|
|
502
522
|
if info.TokenType == 'authorization_code':
|
|
523
|
+
|
|
524
|
+
# set defaults used by token type "authorization_code".
|
|
525
|
+
# - set the clientKey value to null, as it is not used to encrypt / decrypt anything.
|
|
526
|
+
# - include origin device information in the addUser request.
|
|
527
|
+
# - set the tokenType to 'authorization_code'.
|
|
503
528
|
clientKey = ''
|
|
504
529
|
includeOriginDeviceInfo = True
|
|
505
530
|
tokenType = info.TokenType
|
|
531
|
+
|
|
532
|
+
# get authorization_code access token.
|
|
533
|
+
blob:str = self._GetSpotifyConnectAuthorizationCodeToken(info)
|
|
506
534
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
#
|
|
510
|
-
|
|
511
|
-
#
|
|
512
|
-
#
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
#
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
#builder._OriginDeviceName = 'THLUCASI9'
|
|
521
|
-
#builder._OriginDeviceId = '80da4987232671a83397682373f7ce21ea92f2e4'
|
|
522
|
-
#loginId = 'bc5ac8ea51ec48477ff2303d3934db9f'
|
|
523
|
-
# the following is a refresh_token value:
|
|
524
|
-
#blob = 'AQAOzwQB9mAQZcHiqaJgXuaDF88hW3Nt9UDXldqpQfZ6GJPmoSZ2eSN5HC3TQg08r9kZlNch6cJg4kQYysyRzs_S3TTMWHBohpvBLEnoR6_UePhzJVKbjP9exb8MAvUDdjE'
|
|
525
|
-
|
|
535
|
+
else:
|
|
536
|
+
|
|
537
|
+
# set defaults used by this token type:
|
|
538
|
+
# - clientKey should be the getInfo response PublicKey (base64 encoded).
|
|
539
|
+
# - exclude origin device information from the addUser request.
|
|
540
|
+
# - tokenType should be set to 'default'.
|
|
541
|
+
clientKey:str = builder.dh_keys.PublicKeyBase64String
|
|
542
|
+
includeOriginDeviceInfo = False
|
|
543
|
+
tokenType = 'default'
|
|
544
|
+
|
|
545
|
+
# formulate the blob.
|
|
546
|
+
blob = builder.build()
|
|
547
|
+
|
|
526
548
|
# set request parameters.
|
|
527
549
|
reqData={
|
|
528
550
|
'action': 'addUser',
|
|
@@ -530,10 +552,10 @@ class ZeroconfConnect:
|
|
|
530
552
|
'tokenType': tokenType,
|
|
531
553
|
'clientKey': clientKey,
|
|
532
554
|
'loginId': loginId or '', # canonical login id (e.g. "31l77fd87g8h9j00k89f07jf87ge")
|
|
533
|
-
'userName': credentials.username.decode('ascii'), # user name (e.g. "
|
|
555
|
+
'userName': credentials.username.decode('ascii'), # canonical user name (e.g. "31l77fd87g8h9j00k89f07jf87ge")
|
|
534
556
|
'blob': blob,
|
|
535
557
|
}
|
|
536
|
-
|
|
558
|
+
|
|
537
559
|
# are we including origin device information (e.g. deviceName, deviceId)?
|
|
538
560
|
# the `deviceName` and `deviceId` keys are specified by some manufacturers for the
|
|
539
561
|
# `addUser` action. I am not sure if they are required or not, as they are not part
|
|
@@ -552,6 +574,7 @@ class ZeroconfConnect:
|
|
|
552
574
|
reqData['clientKey'] = ''
|
|
553
575
|
|
|
554
576
|
# trace.
|
|
577
|
+
_logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
|
|
555
578
|
_logsi.LogDictionary(SILevel.Verbose, "ZeroconfConnect http request: '%s' (data)" % (endpoint), reqData)
|
|
556
579
|
|
|
557
580
|
# execute spotify zeroconf api request.
|
|
@@ -572,6 +595,198 @@ class ZeroconfConnect:
|
|
|
572
595
|
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)
|
|
573
596
|
|
|
574
597
|
|
|
598
|
+
def _GetSpotifyConnectAuthorizationCodeToken(
|
|
599
|
+
self,
|
|
600
|
+
info:ZeroconfGetInfo,
|
|
601
|
+
) -> str:
|
|
602
|
+
"""
|
|
603
|
+
Exchange a Spotify access token for an authorization_code access token.
|
|
604
|
+
The resulting token will be used as the `blob` argument for the Spotify Connect Zeroconf `addUser` request.
|
|
605
|
+
"""
|
|
606
|
+
apiMethodName:str = '_GetSpotifyConnectAuthorizationCodeToken'
|
|
607
|
+
apiMethodParms:SIMethodParmListContext = None
|
|
608
|
+
tracePrefix:str = 'SpotifyConnectAuthorizationCodeToken exchange'
|
|
609
|
+
|
|
610
|
+
# based upon Fiddler trace, it the tokenType "authorization_code" flow exchanges an
|
|
611
|
+
# access-token for an "authorization_code" token. it does this by calling
|
|
612
|
+
# "accounts.spotify.com/api/token" to exchange the spotify "login5" "client-token" value
|
|
613
|
+
# to an "authorization_code" token value, which is then passed on the Spotify Connect
|
|
614
|
+
# Zeroconf "adduser" call as the "blob" parameter.
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
|
|
618
|
+
# trace.
|
|
619
|
+
apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
|
|
620
|
+
apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
|
|
621
|
+
apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
|
|
622
|
+
apiMethodParms.AppendKeyValue("info.BrandDisplayName", info.BrandDisplayName)
|
|
623
|
+
apiMethodParms.AppendKeyValue("info.ModelDisplayName", info.ModelDisplayName)
|
|
624
|
+
_logsi.LogMethodParmList(SILevel.Verbose, "Preparing to generate a new authorization_code access token for Spotify Connect device: '%s' (%s)" % (info.RemoteName, info.DeviceId), apiMethodParms)
|
|
625
|
+
|
|
626
|
+
# set variables for spotify authorization_code token generation.
|
|
627
|
+
# the redirecy uri variables were obtained via Fiddler trace using Spotify Desktop App to initiate Spotify Connect to device.
|
|
628
|
+
redirectUriHost:str = '127.0.0.1'
|
|
629
|
+
redirectUriPort:str = 4381
|
|
630
|
+
redirectUriPath:str = '/login'
|
|
631
|
+
authorizationType:str = 'Authorization Code PKCE'
|
|
632
|
+
tokenProfileId:str = 'SpotifyDesktopApp_Scopes_Streaming' # 'SpotifyDesktopApp_Scopes_Streaming', 'SpotifyDesktopApp_Scopes_All', 'SpotifyDesktopApp_Scopes_NoExtras'
|
|
633
|
+
SPOTIFY_DESKTOP_APP_CLIENT_ID:str = '65b708073fc0480ea92a077233ca87bd' # Spotify Desktop App client id
|
|
634
|
+
|
|
635
|
+
# Spotify Desktop App scopes requested for Spotify Connect
|
|
636
|
+
SPOTIFY_SCOPES:list = \
|
|
637
|
+
[
|
|
638
|
+
# 'playlist-modify-private',
|
|
639
|
+
# 'playlist-modify-public',
|
|
640
|
+
# 'playlist-read-collaborative',
|
|
641
|
+
# 'playlist-read-private',
|
|
642
|
+
# 'ugc-image-upload',
|
|
643
|
+
# 'user-follow-modify',
|
|
644
|
+
# 'user-follow-read',
|
|
645
|
+
# 'user-library-modify',
|
|
646
|
+
# 'user-library-read',
|
|
647
|
+
# 'user-modify-playback-state',
|
|
648
|
+
# 'user-read-currently-playing',
|
|
649
|
+
# 'user-read-email',
|
|
650
|
+
# 'user-read-playback-position',
|
|
651
|
+
# 'user-read-playback-state',
|
|
652
|
+
# 'user-read-private',
|
|
653
|
+
# 'user-read-recently-played',
|
|
654
|
+
# 'user-top-read',
|
|
655
|
+
# *** streaming scope
|
|
656
|
+
'streaming',
|
|
657
|
+
# *** extra scopes used by spotify desktop player
|
|
658
|
+
# 'app-remote-control',
|
|
659
|
+
# 'playlist-modify',
|
|
660
|
+
# 'playlist-read',
|
|
661
|
+
# 'user-modify',
|
|
662
|
+
# 'user-modify-private',
|
|
663
|
+
# 'user-personalized',
|
|
664
|
+
# 'user-read-birthdate',
|
|
665
|
+
# 'user-read-play-history',
|
|
666
|
+
]
|
|
667
|
+
|
|
668
|
+
# create oauth provider for spotify authentication code with pkce.
|
|
669
|
+
_logsi.LogVerbose('creating OAuth2 provider for Spotify Authentication Code with PKCE')
|
|
670
|
+
authClient:AuthClient = AuthClient(
|
|
671
|
+
authorizationType=authorizationType,
|
|
672
|
+
authorizationUrl=SPOTIFY_API_AUTHORIZE_URL,
|
|
673
|
+
tokenUrl=SPOTIFY_API_TOKEN_URL,
|
|
674
|
+
scope=SPOTIFY_SCOPES,
|
|
675
|
+
clientId=SPOTIFY_DESKTOP_APP_CLIENT_ID,
|
|
676
|
+
tokenStorageDir=self.TokenStorageDir,
|
|
677
|
+
tokenProviderId='SpotifyWebApiAuthCodePkce',
|
|
678
|
+
tokenProfileId=tokenProfileId,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
# force the user to logon to spotify to authorize the application access if we
|
|
682
|
+
# do not have an authorized access token, or if the calling application requested
|
|
683
|
+
# us (by force) to re-authorize, or if the scope has changed.
|
|
684
|
+
isAuthorized = authClient.IsAuthorized
|
|
685
|
+
_logsi.LogVerbose('Checking OAuth2 authorization status: IsAuthorized=%s' % isAuthorized)
|
|
686
|
+
|
|
687
|
+
if (isAuthorized == False):
|
|
688
|
+
|
|
689
|
+
# at this point, we need a new authorization token.
|
|
690
|
+
# the user has up to 2 minutes to respond to the request by copying / pasting the
|
|
691
|
+
# message / auth approval url from the system log into a browser window that is
|
|
692
|
+
# running on the local machine.
|
|
693
|
+
_logsi.LogVerbose('Preparing to retrieve a new OAuth2 authorization access token')
|
|
694
|
+
authClient.AuthorizeWithServer(
|
|
695
|
+
host=redirectUriHost,
|
|
696
|
+
port=redirectUriPort,
|
|
697
|
+
redirect_uri_path=redirectUriPath,
|
|
698
|
+
open_browser=False,
|
|
699
|
+
timeout_seconds=120
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
else:
|
|
703
|
+
|
|
704
|
+
_logsi.LogVerbose('OAuth2 authorization token has previously been authorized')
|
|
705
|
+
|
|
706
|
+
# process results.
|
|
707
|
+
oauth2token:dict = authClient.Session.token
|
|
708
|
+
authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
|
|
709
|
+
|
|
710
|
+
# does token need to be refreshed?
|
|
711
|
+
if authToken.IsExpired:
|
|
712
|
+
|
|
713
|
+
# refresh the token.
|
|
714
|
+
# this will also store the refreshed token to disk to be used later if required.
|
|
715
|
+
_logsi.LogVerbose('OAuth2 authorization token has expired, or is about to; token will be refreshed')
|
|
716
|
+
oauth2token:dict = authClient.RefreshToken()
|
|
717
|
+
authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
|
|
718
|
+
_logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT % apiMethodName, authToken, excludeNonPublic=True)
|
|
719
|
+
|
|
720
|
+
else:
|
|
721
|
+
|
|
722
|
+
_logsi.LogVerbose('OAuth2 authorization token has not expired')
|
|
723
|
+
|
|
724
|
+
# set spotify token request headers.
|
|
725
|
+
tokHeaders:dict = {}
|
|
726
|
+
tokHeaders['User-Agent'] = 'Spotify/124300420 Win32_x86_64/0 (PC desktop)'
|
|
727
|
+
tokHeaders['Accept-Language'] = 'en-Latn-US,en-US;q=0.9,en-Latn;q=0.8,en;q=0.7'
|
|
728
|
+
tokHeaders['Accept-Encoding'] = 'gzip, deflate, br, zstd'
|
|
729
|
+
|
|
730
|
+
# TEST TODO set client-token (for testing purposes).
|
|
731
|
+
#tokHeaders['client-token'] = 'AADEtsyn61 ... redacted ...'
|
|
732
|
+
|
|
733
|
+
# TEST TODO set authorization token (for testing purposes).
|
|
734
|
+
# tokHeaders['authorization'] = 'Bearer BQCYWYv266 ... redacted ...'
|
|
735
|
+
# tokHeaders['authorization'] = authToken.HeaderValue
|
|
736
|
+
# tokHeaders['client-token'] = ''
|
|
737
|
+
|
|
738
|
+
# set audience based upon the device info.
|
|
739
|
+
audience:str = info.ClientId
|
|
740
|
+
|
|
741
|
+
# reset audience based upon specific device brand / model info.
|
|
742
|
+
if info.IsBrandSonos:
|
|
743
|
+
audience = '9b377073ea334637b1406f329ce005de', # Sonos Spotify application id
|
|
744
|
+
|
|
745
|
+
# set spotify token request parameters.
|
|
746
|
+
tokData={
|
|
747
|
+
'audience': audience, # Brand-specific client id that can use the generated token
|
|
748
|
+
'client_id': SPOTIFY_DESKTOP_APP_CLIENT_ID, # Spotify desktop app client id
|
|
749
|
+
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
750
|
+
'requested_token_type': 'urn:spotify:params:oauth:authorization_code',
|
|
751
|
+
'resource': 'urn:spotify:resources:connect',
|
|
752
|
+
'scope': 'streaming',
|
|
753
|
+
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
|
|
754
|
+
'subject_token': authToken.AccessToken,
|
|
755
|
+
# TEST TODO set subject token (for testing purposes).
|
|
756
|
+
#'subject_token': 'BQBBc-0WrN ... redacted ...',
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
# trace.
|
|
760
|
+
_logsi.LogVerbose('Exchanging OAuth2 authorization access token for an authorization_code access token')
|
|
761
|
+
_logsi.LogDictionary(SILevel.Debug, "%s http request: '%s' (headers)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokHeaders)
|
|
762
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http request: '%s' (data)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokData)
|
|
763
|
+
|
|
764
|
+
# execute spotify access token request.
|
|
765
|
+
response = requests.post(
|
|
766
|
+
SPOTIFY_API_TOKEN_URL,
|
|
767
|
+
timeout=10,
|
|
768
|
+
headers=tokHeaders,
|
|
769
|
+
data=tokData
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# check response for initial errors, and return json response.
|
|
773
|
+
responseData:dict = self._CheckResponseForErrors(response, apiMethodName, SPOTIFY_API_TOKEN_URL, tracePrefix)
|
|
774
|
+
result = responseData.get('access_token', None)
|
|
775
|
+
errorMsg = responseData.get('error', None)
|
|
776
|
+
|
|
777
|
+
# check for request errors.
|
|
778
|
+
if response.status_code != 200:
|
|
779
|
+
raise SpotifyZeroconfApiError(response.status_code, 'Spotify OAuth2 Token Exchange failed: %s' % (errorMsg or responseData), apiMethodName, response.reason, _logsi)
|
|
780
|
+
|
|
781
|
+
# return authorization_code access token to caller.
|
|
782
|
+
return result
|
|
783
|
+
|
|
784
|
+
finally:
|
|
785
|
+
|
|
786
|
+
# trace.
|
|
787
|
+
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)
|
|
788
|
+
|
|
789
|
+
|
|
575
790
|
def Disconnect(
|
|
576
791
|
self,
|
|
577
792
|
delay:float=0.50,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|