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.

Files changed (23) hide show
  1. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/METADATA +1 -1
  2. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/RECORD +23 -23
  3. spotifywebapipython/const.py +1 -1
  4. spotifywebapipython/docs/__init__.html +19 -2
  5. spotifywebapipython/docs/index.html +2 -2
  6. spotifywebapipython/docs/search.js +1 -1
  7. spotifywebapipython/docs/spotifywebapiPython/const.html +3 -3
  8. spotifywebapipython/docs/spotifywebapiPython/saappmessages.html +2 -2
  9. spotifywebapipython/docs/spotifywebapiPython/sautils.html +2 -2
  10. spotifywebapipython/docs/spotifywebapiPython/spotifyapierror.html +2 -2
  11. spotifywebapipython/docs/spotifywebapiPython/spotifyapimessage.html +2 -2
  12. spotifywebapipython/docs/spotifywebapiPython/spotifyauthtoken.html +2 -2
  13. spotifywebapipython/docs/spotifywebapiPython/spotifyclient.html +116 -59
  14. spotifywebapipython/docs/spotifywebapiPython/spotifydiscovery.html +2 -2
  15. spotifywebapipython/docs/spotifywebapiPython/spotifywebapiauthenticationerror.html +2 -2
  16. spotifywebapipython/docs/spotifywebapiPython/spotifywebapierror.html +2 -2
  17. spotifywebapipython/oauthcli/authclient.py +10 -11
  18. spotifywebapipython/spotifyclient.py +290 -154
  19. spotifywebapipython/zeroconfapi/blobbuilder.py +7 -12
  20. spotifywebapipython/zeroconfapi/zeroconfconnect.py +259 -44
  21. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/LICENSE +0 -0
  22. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.78.dist-info}/WHEEL +0 -0
  23. {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
- write_int(0x49, blob)
136
- # username
137
- write_bytes(self.credentials.username, blob)
138
- # 'P'
139
- write_int(0x50, blob)
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, 'ZeroconfConnect http response object - type="%s", module="%s"' % (type(response).__name__, type(response).__module__), response)
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, "ZeroconfConnect http response [%s-%s]: '%s' (response)" % (response.status_code, response.reason, endpoint), response)
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, "ZeroconfConnect http response [%s-%s]: '%s' (headers)" % (response.status_code, response.reason, endpoint), response.headers.items())
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, "ZeroconfConnect http response [%s-%s]: '%s' (json dict)" % (response.status_code, response.reason, endpoint), responseData, prettyPrint=True)
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, "ZeroconfConnect http response [%s-%s]: '%s' (json array)" % (response.status_code, response.reason, endpoint), responseData)
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, "ZeroconfConnect http response [%s-%s]: '%s' (json object)" % (response.status_code, response.reason, endpoint), responseData)
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("ZeroconfConnect http response could not be converted to JSON and will be converted to utf-8.\nConversion exception returned was:\n{ex}".format(ex=str(ex)), ex, logToSystemLogger=False)
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, "ZeroconfConnect http response [%s-%s]: '%s' (utf-8)" % (response.status_code, response.reason, endpoint), responseUTF8)
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
- _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
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
- # TODO - not sure of the blob credentials layout for TokenType == 'authorization_code'
508
- # it seems like the `blob=` value is a serialized token, and NOT an encrypted blob like AuthenticationTypes.USER_PASS!
509
- # the `loginid=` value seems to change with every request from the Spotify Desktop App.
510
-
511
- # get a Spotify WebServices API auth token that the Sonos device can use.
512
- #refreshToken = 'AQD8pYMsggRYaRHfCz-Zgp0finaPGSKgwn-P_YocybKicrvOhdrZMzbUVDTXNGA7l-7oYxxN9-zgg3F-SYvazl0SbOXPB5MybIr7mI2LihrmDdWPW7LQvlbAod3RCeIP6wE'
513
- #accessToken = 'BQBU9ZBSQ4Zax6_maNqzy-Ux2ovCDorJ0CqZMi6NbCMog4y71i4OO9cby_YxPyP3BAS2wrVnsfln7qG8XbdlD1jfegKO7Ko-DzNr4X8uckqazkNDxmLrc4HlW74c5sEG5Kj8b1TqfzOEp2Cu2b7XSpLhkmP2ODaqAUNc-8_PxLowb3KYF1kyf2RdLGTkE7aylvAfbJWnADInudcAjkUxTX1Q313a-wMr0pX2MpQcYc6fSLRAFI1E_DC_uuGsrZJZ3V9GTDQTcGK893DYUilvvhj5FdZKJyXl5RlPvCo5fyde673l7RfsRqk054ggh0mjIf-6G7-h-_ah_ZvcMtqcvg'
514
- #credentials:Credentials = Credentials(username, password, AuthenticationTypes.SPOTIFY_TOKEN) # authToken is the password
515
- #credentials:Credentials = Credentials(username, accessToken, AuthenticationTypes.SPOTIFY_TOKEN) # authToken is the password
516
- #credentials:Credentials = Credentials('', authToken, AuthenticationTypes.SPOTIFY_TOKEN) # authToken is the password
517
- #builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
518
- #blob = builder.build()
519
- #blob = 'AQCM8WtUeLfADIVlADjApWtb6uggIM10uhKbH28PbE3362pHXKFcvak1QgO_szZAGgtY5UEuHjNKKfpygkK_cbJRuQXkmuMNoTNFwCoQM7Wa2CZriPmqPExEls3Q4GN_rJeYPgF5JtfGgGgrLQ'
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. "youremail@mail.com")
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,