spotifywebapiPython 1.0.76__py3-none-any.whl → 1.0.77__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.77.dist-info}/METADATA +1 -1
  2. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.77.dist-info}/RECORD +23 -23
  3. spotifywebapipython/const.py +1 -1
  4. spotifywebapipython/docs/__init__.html +13 -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 +109 -53
  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 +282 -150
  19. spotifywebapipython/zeroconfapi/blobbuilder.py +7 -12
  20. spotifywebapipython/zeroconfapi/zeroconfconnect.py +244 -43
  21. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.77.dist-info}/LICENSE +0 -0
  22. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.77.dist-info}/WHEEL +0 -0
  23. {spotifywebapiPython-1.0.76.dist-info → spotifywebapiPython-1.0.77.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
@@ -145,7 +150,7 @@ class ZeroconfConnect:
145
150
  return self._Version or ''
146
151
 
147
152
 
148
- def _CheckResponseForErrors(self, response:Response, methodName:str, endpoint:str) -> None:
153
+ def _CheckResponseForErrors(self, response:Response, methodName:str, endpoint:str, tracePrefix:str='ZeroconfConnect') -> None:
149
154
  """
150
155
  Checks the Spotify Zeroconf API response for errors.
151
156
 
@@ -162,6 +167,9 @@ class ZeroconfConnect:
162
167
  method name that made the request (for trace purposes).
163
168
  endpoint (str):
164
169
  Endpoint that was requested (for trace purposes).
170
+ tracePrefix (str):
171
+ Prefix text used in trace log messages.
172
+ Default is "ZeroconfConnect".
165
173
 
166
174
  Raises:
167
175
  SpotifyZeroconfApiError:
@@ -174,13 +182,13 @@ class ZeroconfConnect:
174
182
 
175
183
  # trace.
176
184
  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)
185
+ _logsi.LogObject(SILevel.Debug, '%s http response object - type="%s", module="%s"' % (tracePrefix, type(response).__name__, type(response).__module__), response)
178
186
 
179
187
  # trace.
180
188
  if _logsi.IsOn(SILevel.Debug):
181
- _logsi.LogObject(SILevel.Debug, "ZeroconfConnect http response [%s-%s]: '%s' (response)" % (response.status_code, response.reason, endpoint), response)
189
+ _logsi.LogObject(SILevel.Debug, "%s http response [%s-%s]: '%s' (response)" % (tracePrefix, response.status_code, response.reason, endpoint), response)
182
190
  if (response.headers):
183
- _logsi.LogCollection(SILevel.Debug, "ZeroconfConnect http response [%s-%s]: '%s' (headers)" % (response.status_code, response.reason, endpoint), response.headers.items())
191
+ _logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s]: '%s' (headers)" % (tracePrefix, response.status_code, response.reason, endpoint), response.headers.items())
184
192
 
185
193
  # do we have response data?
186
194
  if (response.content is not None) and (len(response.content) > 0):
@@ -193,11 +201,11 @@ class ZeroconfConnect:
193
201
 
194
202
  if _logsi.IsOn(SILevel.Verbose):
195
203
  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)
204
+ _logsi.LogDictionary(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json dict)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData, prettyPrint=True)
197
205
  elif isinstance(responseData, list):
198
- _logsi.LogArray(SILevel.Verbose, "ZeroconfConnect http response [%s-%s]: '%s' (json array)" % (response.status_code, response.reason, endpoint), responseData)
206
+ _logsi.LogArray(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json array)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
199
207
  else:
200
- _logsi.LogObject(SILevel.Verbose, "ZeroconfConnect http response [%s-%s]: '%s' (json object)" % (response.status_code, response.reason, endpoint), responseData)
208
+ _logsi.LogObject(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json object)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
201
209
 
202
210
  else:
203
211
 
@@ -209,12 +217,12 @@ class ZeroconfConnect:
209
217
  except SpotifyApiError: raise # pass handled exceptions on thru
210
218
  except Exception as ex:
211
219
 
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)
220
+ _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
221
 
214
222
  # if json conversion failed, then convert to utf-8 response.
215
223
  if response.content is not None:
216
224
  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)
225
+ _logsi.LogText(SILevel.Error, "%s http response [%s-%s]: '%s' (utf-8)" % (tracePrefix, response.status_code, response.reason, endpoint), responseUTF8)
218
226
 
219
227
  # at this point we don't know what Spotify Web Api returned!
220
228
  # it's been found that some devices (Sonos, etc) do not return a proper JSON response.
@@ -465,29 +473,29 @@ class ZeroconfConnect:
465
473
  apiMethodParms.AppendKeyValue("info.PublicKey", info.PublicKey)
466
474
  apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
467
475
  apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
476
+ apiMethodParms.AppendKeyValue("info.TokenType", info.TokenType)
468
477
  _logsi.LogMethodParmList(SILevel.Verbose, "Issuing Spotify Connect Zeroconf addUser request (ip=%s)" % self._HostIpAddress, apiMethodParms)
469
478
 
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
479
  # set request endpoint.
476
480
  endpoint:str = self.GetEndpoint('addUser')
477
481
 
478
482
  # set request headers.
479
483
  reqHeaders:dict = {
480
484
  'Content-Type': 'application/x-www-form-urlencoded',
481
- 'Connection': 'close'
485
+ #'Connection': 'close',
486
+ 'Connection': 'keep-alive',
487
+ 'Keep-Alive': '0',
488
+ 'Accept-Encoding': 'gzip',
482
489
  }
483
- _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
490
+
491
+ # create credentials and builder objects.
492
+ credentials:Credentials = Credentials(username, password, AuthenticationTypes.USER_PASS)
493
+ builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
484
494
 
485
495
  # 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).
496
+ # logic below for specific manufacturers / devices as required.
488
497
  # - exclude origin device information from the addUser request.
489
498
  # - tokenType should be set to 'default'.
490
- clientKey:str = builder.dh_keys.PublicKeyBase64String
491
499
  includeOriginDeviceInfo:bool = False
492
500
  tokenType:str = 'default'
493
501
 
@@ -496,33 +504,32 @@ class ZeroconfConnect:
496
504
  includeOriginDeviceInfo = True
497
505
 
498
506
  # 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
507
  if info.TokenType == 'authorization_code':
508
+
509
+ # set defaults used by token type "authorization_code".
510
+ # - set the clientKey value to null, as it is not used to encrypt / decrypt anything.
511
+ # - include origin device information in the addUser request.
512
+ # - set the tokenType to 'authorization_code'.
503
513
  clientKey = ''
504
514
  includeOriginDeviceInfo = True
505
515
  tokenType = info.TokenType
516
+
517
+ # get authorization_code access token.
518
+ blob:str = self._GetSpotifyConnectAuthorizationCodeToken(info)
506
519
 
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
-
520
+ else:
521
+
522
+ # set defaults used by this token type:
523
+ # - clientKey should be the getInfo response PublicKey (base64 encoded).
524
+ # - exclude origin device information from the addUser request.
525
+ # - tokenType should be set to 'default'.
526
+ clientKey:str = builder.dh_keys.PublicKeyBase64String
527
+ includeOriginDeviceInfo = False
528
+ tokenType = 'default'
529
+
530
+ # formulate the blob.
531
+ blob = builder.build()
532
+
526
533
  # set request parameters.
527
534
  reqData={
528
535
  'action': 'addUser',
@@ -530,10 +537,10 @@ class ZeroconfConnect:
530
537
  'tokenType': tokenType,
531
538
  'clientKey': clientKey,
532
539
  'loginId': loginId or '', # canonical login id (e.g. "31l77fd87g8h9j00k89f07jf87ge")
533
- 'userName': credentials.username.decode('ascii'), # user name (e.g. "youremail@mail.com")
540
+ 'userName': credentials.username.decode('ascii'), # canonical user name (e.g. "31l77fd87g8h9j00k89f07jf87ge")
534
541
  'blob': blob,
535
542
  }
536
-
543
+
537
544
  # are we including origin device information (e.g. deviceName, deviceId)?
538
545
  # the `deviceName` and `deviceId` keys are specified by some manufacturers for the
539
546
  # `addUser` action. I am not sure if they are required or not, as they are not part
@@ -552,6 +559,7 @@ class ZeroconfConnect:
552
559
  reqData['clientKey'] = ''
553
560
 
554
561
  # trace.
562
+ _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
555
563
  _logsi.LogDictionary(SILevel.Verbose, "ZeroconfConnect http request: '%s' (data)" % (endpoint), reqData)
556
564
 
557
565
  # execute spotify zeroconf api request.
@@ -572,6 +580,199 @@ class ZeroconfConnect:
572
580
  _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
573
581
 
574
582
 
583
+ def _GetSpotifyConnectAuthorizationCodeToken(
584
+ self,
585
+ info:ZeroconfGetInfo,
586
+ ) -> str:
587
+ """
588
+ Exchange a Spotify access token for an authorization_code access token.
589
+ The resulting token will be used as the `blob` argument for the Spotify Connect Zeroconf `addUser` request.
590
+ """
591
+ apiMethodName:str = '_GetSpotifyConnectAuthorizationCodeToken'
592
+ apiMethodParms:SIMethodParmListContext = None
593
+ tracePrefix:str = 'SpotifyConnectAuthorizationCodeToken exchange'
594
+
595
+ # based upon Fiddler trace, it the tokenType "authorization_code" flow exchanges an
596
+ # access-token for an "authorization_code" token. it does this by calling
597
+ # "accounts.spotify.com/api/token" to exchange the spotify "login5" "client-token" value
598
+ # to an "authorization_code" token value, which is then passed on the Spotify Connect
599
+ # Zeroconf "adduser" call as the "blob" parameter.
600
+
601
+ try:
602
+
603
+ # trace.
604
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
605
+ apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
606
+ apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
607
+ apiMethodParms.AppendKeyValue("info.BrandDisplayName", info.BrandDisplayName)
608
+ apiMethodParms.AppendKeyValue("info.ModelDisplayName", info.ModelDisplayName)
609
+ _logsi.LogMethodParmList(SILevel.Verbose, "Preparing to generate a new authorization_code access token for Spotify Connect device: '%s' (%s)" % (info.RemoteName, info.DeviceId), apiMethodParms)
610
+
611
+ # set variables for spotify authorization_code token generation.
612
+ # the redirecy uri variables were obtained via Fiddler trace using Spotify Desktop App to initiate Spotify Connect to device.
613
+ redirectUriHost:str = '127.0.0.1'
614
+ redirectUriPort:str = 4381
615
+ redirectUriPath:str = '/login'
616
+ authorizationType:str = 'Authorization Code PKCE'
617
+ tokenStorageDir:str = './test/testdata'
618
+ tokenProfileId:str = 'SpotifyDesktopApp_Scopes_Streaming' # 'SpotifyDesktopApp_Scopes_Streaming', 'SpotifyDesktopApp_Scopes_All', 'SpotifyDesktopApp_Scopes_NoExtras'
619
+ SPOTIFY_DESKTOP_APP_CLIENT_ID:str = '65b708073fc0480ea92a077233ca87bd' # Spotify Desktop App client id
620
+
621
+ # Spotify Desktop App scopes requested for Spotify Connect
622
+ SPOTIFY_SCOPES:list = \
623
+ [
624
+ # 'playlist-modify-private',
625
+ # 'playlist-modify-public',
626
+ # 'playlist-read-collaborative',
627
+ # 'playlist-read-private',
628
+ # 'ugc-image-upload',
629
+ # 'user-follow-modify',
630
+ # 'user-follow-read',
631
+ # 'user-library-modify',
632
+ # 'user-library-read',
633
+ # 'user-modify-playback-state',
634
+ # 'user-read-currently-playing',
635
+ # 'user-read-email',
636
+ # 'user-read-playback-position',
637
+ # 'user-read-playback-state',
638
+ # 'user-read-private',
639
+ # 'user-read-recently-played',
640
+ # 'user-top-read',
641
+ # *** streaming scope
642
+ 'streaming',
643
+ # *** extra scopes used by spotify desktop player
644
+ # 'app-remote-control',
645
+ # 'playlist-modify',
646
+ # 'playlist-read',
647
+ # 'user-modify',
648
+ # 'user-modify-private',
649
+ # 'user-personalized',
650
+ # 'user-read-birthdate',
651
+ # 'user-read-play-history',
652
+ ]
653
+
654
+ # create oauth provider for spotify authentication code with pkce.
655
+ _logsi.LogVerbose('creating OAuth2 provider for Spotify Authentication Code with PKCE')
656
+ authClient:AuthClient = AuthClient(
657
+ authorizationType=authorizationType,
658
+ authorizationUrl=SPOTIFY_API_AUTHORIZE_URL,
659
+ tokenUrl=SPOTIFY_API_TOKEN_URL,
660
+ scope=SPOTIFY_SCOPES,
661
+ clientId=SPOTIFY_DESKTOP_APP_CLIENT_ID,
662
+ tokenStorageDir=tokenStorageDir,
663
+ tokenProviderId='SpotifyWebApiAuthCodePkce',
664
+ tokenProfileId=tokenProfileId,
665
+ )
666
+
667
+ # force the user to logon to spotify to authorize the application access if we
668
+ # do not have an authorized access token, or if the calling application requested
669
+ # us (by force) to re-authorize, or if the scope has changed.
670
+ isAuthorized = authClient.IsAuthorized
671
+ _logsi.LogVerbose('Checking OAuth2 authorization status: IsAuthorized=%s' % isAuthorized)
672
+
673
+ if (isAuthorized == False):
674
+
675
+ # at this point, we need a new authorization token.
676
+ # the user has up to 2 minutes to respond to the request by copying / pasting the
677
+ # message / auth approval url from the system log into a browser window that is
678
+ # running on the local machine.
679
+ _logsi.LogVerbose('Preparing to retrieve a new OAuth2 authorization access token')
680
+ authClient.AuthorizeWithServer(
681
+ host=redirectUriHost,
682
+ port=redirectUriPort,
683
+ redirect_uri_path=redirectUriPath,
684
+ open_browser=False,
685
+ timeout_seconds=120
686
+ )
687
+
688
+ else:
689
+
690
+ _logsi.LogVerbose('OAuth2 authorization token has previously been authorized')
691
+
692
+ # process results.
693
+ oauth2token:dict = authClient.Session.token
694
+ authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
695
+
696
+ # does token need to be refreshed?
697
+ if authToken.IsExpired:
698
+
699
+ # refresh the token.
700
+ # this will also store the refreshed token to disk to be used later if required.
701
+ _logsi.LogVerbose('OAuth2 authorization token has expired, or is about to; token will be refreshed')
702
+ oauth2token:dict = authClient.RefreshToken()
703
+ authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
704
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT % apiMethodName, authToken, excludeNonPublic=True)
705
+
706
+ else:
707
+
708
+ _logsi.LogVerbose('OAuth2 authorization token has not expired')
709
+
710
+ # set spotify token request headers.
711
+ tokHeaders:dict = {}
712
+ tokHeaders['User-Agent'] = 'Spotify/124300420 Win32_x86_64/0 (PC desktop)'
713
+ tokHeaders['Accept-Language'] = 'en-Latn-US,en-US;q=0.9,en-Latn;q=0.8,en;q=0.7'
714
+ tokHeaders['Accept-Encoding'] = 'gzip, deflate, br, zstd'
715
+
716
+ # TEST TODO set client-token (for testing purposes).
717
+ #tokHeaders['client-token'] = 'AADEtsyn61 ... redacted ...'
718
+
719
+ # TEST TODO set authorization token (for testing purposes).
720
+ # tokHeaders['authorization'] = 'Bearer BQCYWYv266 ... redacted ...'
721
+ # tokHeaders['authorization'] = authToken.HeaderValue
722
+ # tokHeaders['client-token'] = ''
723
+
724
+ # set audience based upon the device info.
725
+ audience:str = info.ClientId
726
+
727
+ # reset audience based upon specific device brand / model info.
728
+ if info.IsBrandSonos:
729
+ audience = '9b377073ea334637b1406f329ce005de', # Sonos Spotify application id
730
+
731
+ # set spotify token request parameters.
732
+ tokData={
733
+ 'audience': audience, # Brand-specific client id that can use the generated token
734
+ 'client_id': SPOTIFY_DESKTOP_APP_CLIENT_ID, # Spotify desktop app client id
735
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
736
+ 'requested_token_type': 'urn:spotify:params:oauth:authorization_code',
737
+ 'resource': 'urn:spotify:resources:connect',
738
+ 'scope': 'streaming',
739
+ 'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
740
+ 'subject_token': authToken.AccessToken,
741
+ # TEST TODO set subject token (for testing purposes).
742
+ #'subject_token': 'BQBBc-0WrN ... redacted ...',
743
+ }
744
+
745
+ # trace.
746
+ _logsi.LogVerbose('Exchanging OAuth2 authorization access token for an authorization_code access token')
747
+ _logsi.LogDictionary(SILevel.Debug, "%s http request: '%s' (headers)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokHeaders)
748
+ _logsi.LogDictionary(SILevel.Verbose, "%s http request: '%s' (data)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokData)
749
+
750
+ # execute spotify access token request.
751
+ response = requests.post(
752
+ SPOTIFY_API_TOKEN_URL,
753
+ timeout=10,
754
+ headers=tokHeaders,
755
+ data=tokData
756
+ )
757
+
758
+ # check response for initial errors, and return json response.
759
+ responseData:dict = self._CheckResponseForErrors(response, apiMethodName, SPOTIFY_API_TOKEN_URL, tracePrefix)
760
+ result = responseData.get('access_token', None)
761
+ errorMsg = responseData.get('error', None)
762
+
763
+ # check for request errors.
764
+ if response.status_code != 200:
765
+ raise SpotifyZeroconfApiError(response.status_code, 'Spotify OAuth2 Token Exchange failed: %s' % (errorMsg or responseData), apiMethodName, response.reason, _logsi)
766
+
767
+ # return authorization_code access token to caller.
768
+ return result
769
+
770
+ finally:
771
+
772
+ # trace.
773
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
774
+
775
+
575
776
  def Disconnect(
576
777
  self,
577
778
  delay:float=0.50,