spotifywebapiPython 1.0.242__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.
- spotifywebapipython/__init__.py +35 -0
- spotifywebapipython/const.py +114 -0
- spotifywebapipython/docs/__init__.html +2156 -0
- spotifywebapipython/docs/codecopy.js +70 -0
- spotifywebapipython/docs/index.html +514 -0
- spotifywebapipython/docs/search.js +46 -0
- spotifywebapipython/docs/spotifywebapipython/const.html +636 -0
- spotifywebapipython/docs/spotifywebapipython/saappmessages.html +540 -0
- spotifywebapipython/docs/spotifywebapipython/sautils.html +620 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +341 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +598 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +612 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +16557 -0
- spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +518 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +421 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +400 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +635 -0
- spotifywebapipython/docs/spotifywebapipython.ico +0 -0
- spotifywebapipython/docs/spotifywebapipython_OLD.ico +0 -0
- spotifywebapipython/models/__init__.py +121 -0
- spotifywebapipython/models/album.py +170 -0
- spotifywebapipython/models/albumpagesaved.py +85 -0
- spotifywebapipython/models/albumpagesimplified.py +88 -0
- spotifywebapipython/models/albumsaved.py +102 -0
- spotifywebapipython/models/albumsimplified.py +308 -0
- spotifywebapipython/models/artist.py +146 -0
- spotifywebapipython/models/artistinfo.py +282 -0
- spotifywebapipython/models/artistinfotourevent.py +127 -0
- spotifywebapipython/models/artistpage.py +70 -0
- spotifywebapipython/models/artistsimplified.py +163 -0
- spotifywebapipython/models/audiobook.py +87 -0
- spotifywebapipython/models/audiobookpagesimplified.py +88 -0
- spotifywebapipython/models/audiobooksimplified.py +362 -0
- spotifywebapipython/models/audiofeatures.py +333 -0
- spotifywebapipython/models/author.py +67 -0
- spotifywebapipython/models/category.py +168 -0
- spotifywebapipython/models/categorypage.py +70 -0
- spotifywebapipython/models/chapter.py +84 -0
- spotifywebapipython/models/chapterpagesimplified.py +88 -0
- spotifywebapipython/models/chaptersimplified.py +387 -0
- spotifywebapipython/models/context.py +113 -0
- spotifywebapipython/models/copyright.py +79 -0
- spotifywebapipython/models/device.py +301 -0
- spotifywebapipython/models/episode.py +84 -0
- spotifywebapipython/models/episodepagesaved.py +85 -0
- spotifywebapipython/models/episodepagesimplified.py +88 -0
- spotifywebapipython/models/episodesaved.py +99 -0
- spotifywebapipython/models/episodesimplified.py +370 -0
- spotifywebapipython/models/explicitcontent.py +80 -0
- spotifywebapipython/models/externalids.py +91 -0
- spotifywebapipython/models/externalurls.py +104 -0
- spotifywebapipython/models/followers.py +81 -0
- spotifywebapipython/models/imageobject.py +193 -0
- spotifywebapipython/models/imagevibrantcolors.py +182 -0
- spotifywebapipython/models/linkedfrom.py +165 -0
- spotifywebapipython/models/narrator.py +67 -0
- spotifywebapipython/models/owner.py +162 -0
- spotifywebapipython/models/pageobject.py +343 -0
- spotifywebapipython/models/playeractions.py +196 -0
- spotifywebapipython/models/playerlastplayedinfo.py +334 -0
- spotifywebapipython/models/playerplaystate.py +467 -0
- spotifywebapipython/models/playerqueueinfo.py +207 -0
- spotifywebapipython/models/playhistory.py +136 -0
- spotifywebapipython/models/playhistorypage.py +85 -0
- spotifywebapipython/models/playlist.py +125 -0
- spotifywebapipython/models/playlistpage.py +85 -0
- spotifywebapipython/models/playlistpagesimplified.py +107 -0
- spotifywebapipython/models/playlistsimplified.py +334 -0
- spotifywebapipython/models/playlisttrack.py +143 -0
- spotifywebapipython/models/playlisttracksummary.py +80 -0
- spotifywebapipython/models/recommendationseed.py +135 -0
- spotifywebapipython/models/restrictions.py +86 -0
- spotifywebapipython/models/resumepoint.py +79 -0
- spotifywebapipython/models/searchresponse.py +391 -0
- spotifywebapipython/models/searchresultbase.py +299 -0
- spotifywebapipython/models/show.py +87 -0
- spotifywebapipython/models/showpagesaved.py +85 -0
- spotifywebapipython/models/showpagesimplified.py +88 -0
- spotifywebapipython/models/showsaved.py +99 -0
- spotifywebapipython/models/showsimplified.py +320 -0
- spotifywebapipython/models/spotifyconnectdevice.py +321 -0
- spotifywebapipython/models/spotifyconnectdevices.py +441 -0
- spotifywebapipython/models/track.py +142 -0
- spotifywebapipython/models/trackpage.py +110 -0
- spotifywebapipython/models/trackpagesaved.py +85 -0
- spotifywebapipython/models/trackpagesimplified.py +88 -0
- spotifywebapipython/models/trackrecommendations.py +88 -0
- spotifywebapipython/models/tracksaved.py +99 -0
- spotifywebapipython/models/tracksimplified.py +398 -0
- spotifywebapipython/models/userprofile.py +184 -0
- spotifywebapipython/models/userprofilesimplified.py +206 -0
- spotifywebapipython/models/zeroconfdiscoveryresult.py +592 -0
- spotifywebapipython/models/zeroconfproperty.py +93 -0
- spotifywebapipython/oauthcli/__init__.py +7 -0
- spotifywebapipython/oauthcli/authclient.py +1229 -0
- spotifywebapipython/saappmessages.py +86 -0
- spotifywebapipython/sautils.py +285 -0
- spotifywebapipython/spotifyapierror.py +66 -0
- spotifywebapipython/spotifyapimessage.py +269 -0
- spotifywebapipython/spotifyauthtoken.py +264 -0
- spotifywebapipython/spotifyclient.py +17885 -0
- spotifywebapipython/spotifyconnect/__init__.py +19 -0
- spotifywebapipython/spotifyconnect/spotifyconnectdeviceeventargs.py +67 -0
- spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +2564 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastapptask.py +467 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +701 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastlistener.py +359 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +212 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfexceptions.py +20 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconflistener.py +335 -0
- spotifywebapipython/spotifydiscovery.py +473 -0
- spotifywebapipython/spotifymediatypes.py +53 -0
- spotifywebapipython/spotifywebapiauthenticationerror.py +122 -0
- spotifywebapipython/spotifywebapierror.py +106 -0
- spotifywebapipython/spotifywebplayertoken.py +743 -0
- spotifywebapipython/vibrant/__init__.py +17 -0
- spotifywebapipython/vibrant/generator.py +261 -0
- spotifywebapipython/vibrant/image.py +100 -0
- spotifywebapipython/vibrant/main.py +34 -0
- spotifywebapipython/vibrant/models.py +93 -0
- spotifywebapipython/vibrant/utils.py +56 -0
- spotifywebapipython/zeroconfapi/__init__.py +23 -0
- spotifywebapipython/zeroconfapi/authenticationtypes.py +38 -0
- spotifywebapipython/zeroconfapi/blobbuilder.py +370 -0
- spotifywebapipython/zeroconfapi/credentials.py +37 -0
- spotifywebapipython/zeroconfapi/cryptodiffiehellman.py +89 -0
- spotifywebapipython/zeroconfapi/helpers.py +64 -0
- spotifywebapipython/zeroconfapi/spotifyzeroconfapierror.py +108 -0
- spotifywebapipython/zeroconfapi/zeroconfconnect.py +1327 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfo.py +572 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfoalias.py +109 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfodrmmediaformat.py +90 -0
- spotifywebapipython/zeroconfapi/zeroconfresponse.py +195 -0
- spotifywebapipython-1.0.242.dist-info/LICENSE +22 -0
- spotifywebapipython-1.0.242.dist-info/METADATA +219 -0
- spotifywebapipython-1.0.242.dist-info/RECORD +138 -0
- spotifywebapipython-1.0.242.dist-info/WHEEL +5 -0
- spotifywebapipython-1.0.242.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
# external package imports.
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os.path
|
|
6
|
+
import platformdirs
|
|
7
|
+
from pyotp import TOTP
|
|
8
|
+
import requests
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
# our package imports.
|
|
12
|
+
from .saappmessages import SAAppMessages
|
|
13
|
+
from .sautils import GetUnixTimestampMSFromUtcNow
|
|
14
|
+
from .spotifyapierror import SpotifyApiError
|
|
15
|
+
from .const import (
|
|
16
|
+
SPOTIFY_WEBAPI_URL_BASE,
|
|
17
|
+
SPOTIFY_WEBUI_URL_BASE,
|
|
18
|
+
SPOTIFYWEBAPIPYTHON_TOKEN_CACHE_FILE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# get smartinspect logger reference; create a new session for this module name.
|
|
22
|
+
from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext, SIColors
|
|
23
|
+
import logging
|
|
24
|
+
_logsi:SISession = SIAuto.Si.GetSession(__name__)
|
|
25
|
+
if (_logsi == None):
|
|
26
|
+
_logsi = SIAuto.Si.AddSession(__name__, True)
|
|
27
|
+
_logsi.SystemLogger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SPOTIFY_WEBUI_URL_GET_ACCESS_TOKEN = SPOTIFY_WEBUI_URL_BASE + "/get_access_token"
|
|
31
|
+
""" Url used to get access token. """
|
|
32
|
+
|
|
33
|
+
SPOTIFY_WEBUI_URL_GET_SERVER_TIME = SPOTIFY_WEBUI_URL_BASE + "/server-time"
|
|
34
|
+
""" Url used to get Spotify server time. """
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SpotifyWebPlayerToken:
|
|
38
|
+
"""
|
|
39
|
+
Represents a Spotify web player token for an account.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
clientId:str=None,
|
|
45
|
+
profileId:str=None,
|
|
46
|
+
tokenProviderId:str=None,
|
|
47
|
+
tokenStorageDir:str=None,
|
|
48
|
+
tokenStorageFile:str=None,
|
|
49
|
+
spotifyWebPlayerCookieSpdc:str=None,
|
|
50
|
+
spotifyWebPlayerCookieSpkey:str=None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Initializes a new instance of the class.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
clientId (str):
|
|
57
|
+
The unique identifier of the application.
|
|
58
|
+
A null value will default to `Shared`.
|
|
59
|
+
Default: `Shared`
|
|
60
|
+
profileId (str):
|
|
61
|
+
Profile identifier used when loading / storing the token to disk.
|
|
62
|
+
A null value will default to `Shared`.
|
|
63
|
+
Default: `Shared`
|
|
64
|
+
tokenProviderId (str):
|
|
65
|
+
Provider identifier used when storing the token to disk.
|
|
66
|
+
A null value will default to `Shared`.
|
|
67
|
+
Default: `Shared`
|
|
68
|
+
tokenStorageDir (str):
|
|
69
|
+
The directory path that will contain the Token Cache file.
|
|
70
|
+
A null value will default to the platform specific storage location:
|
|
71
|
+
Example for Windows OS = `C:\\ProgramData\\SpotifyWebApiPython`
|
|
72
|
+
tokenStorageFile (str):
|
|
73
|
+
The filename and extension of the Token Cache file.
|
|
74
|
+
Default is `tokens.json`.
|
|
75
|
+
spotifyWebPlayerCookieSpdc (str):
|
|
76
|
+
Spotify Web Player Cookie credentials `sp_dc` value.
|
|
77
|
+
spotifyWebPlayerCookieSpkey (str):
|
|
78
|
+
Spotify Web Player Cookie credentials `sp_key` value.
|
|
79
|
+
|
|
80
|
+
If the `spotifyWebPlayerCookieSpdc` and `spotifyWebPlayerCookieSpkey` values are specified,
|
|
81
|
+
then the Token Cache File parameters will be ignored and a token created from the specified
|
|
82
|
+
values.
|
|
83
|
+
|
|
84
|
+
Otherwise, the Token Cache File is queried to retrieve the `sp_dc` and `sp_key` values.
|
|
85
|
+
"""
|
|
86
|
+
# validations.
|
|
87
|
+
if clientId is None:
|
|
88
|
+
clientId = "Shared"
|
|
89
|
+
if profileId is None:
|
|
90
|
+
profileId = 'Shared'
|
|
91
|
+
|
|
92
|
+
# verify token storage directory exists.
|
|
93
|
+
if tokenStorageDir is None:
|
|
94
|
+
tokenStorageDir = platformdirs.site_config_dir('SpotifyWebApiPython', ensure_exists=True, appauthor=False)
|
|
95
|
+
os.makedirs(tokenStorageDir, exist_ok=True) # succeeds even if directory exists.
|
|
96
|
+
|
|
97
|
+
# verify token storage filename was specified.
|
|
98
|
+
if tokenStorageFile is None:
|
|
99
|
+
tokenStorageFile = SPOTIFYWEBAPIPYTHON_TOKEN_CACHE_FILE
|
|
100
|
+
|
|
101
|
+
# if token providerId not set, then default to shared.
|
|
102
|
+
if tokenProviderId is None:
|
|
103
|
+
tokenProviderId = 'SpotifyWebPlayerCookieCredentials'
|
|
104
|
+
|
|
105
|
+
# initializa storage.
|
|
106
|
+
self._AccessToken = None
|
|
107
|
+
self._ClientId:str = clientId
|
|
108
|
+
self._ExpiresAt:int = 0
|
|
109
|
+
self._ExpireDateTimeUtc:datetime = None
|
|
110
|
+
self._ExpiresIn:int = 0
|
|
111
|
+
self._IsAnonymous:bool = False
|
|
112
|
+
self._ProfileId:str = profileId
|
|
113
|
+
self._TokenProviderId:str = tokenProviderId
|
|
114
|
+
self._TokenStorageDir:str = tokenStorageDir
|
|
115
|
+
self._TokenStorageFile:str = tokenStorageFile
|
|
116
|
+
self._TokenStoragePath:str = os.path.join(tokenStorageDir, tokenStorageFile)
|
|
117
|
+
self._sp_dc:str = spotifyWebPlayerCookieSpdc
|
|
118
|
+
self._sp_key:str = spotifyWebPlayerCookieSpkey
|
|
119
|
+
|
|
120
|
+
# were cookie credentials provided?
|
|
121
|
+
if (spotifyWebPlayerCookieSpdc is not None) and (spotifyWebPlayerCookieSpkey is not None):
|
|
122
|
+
|
|
123
|
+
# yes - create cookie credentials dictionary from parameters.
|
|
124
|
+
token:dict = {
|
|
125
|
+
"sp_dc": spotifyWebPlayerCookieSpdc ,
|
|
126
|
+
"sp_key": spotifyWebPlayerCookieSpkey,
|
|
127
|
+
"title": "SpotifyClient Credentials for user: " + profileId,
|
|
128
|
+
"token_type": "CookieCredentials"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# trace.
|
|
132
|
+
_logsi.LogDictionary(SILevel.Verbose, "Cookie credentials were created from passed parameters for profileId: \"%s\"" % (profileId), token, prettyPrint=True)
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
|
|
136
|
+
# no - load cookie credentials from the token storage file.
|
|
137
|
+
token:dict = self._LoadCookieCredentials()
|
|
138
|
+
|
|
139
|
+
# get Spotify Web Player access token from stored Spotify Web Player cookie credentials.
|
|
140
|
+
self.GetAccessTokenFromCookieCredentials()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def AccessToken(self) -> str:
|
|
145
|
+
"""
|
|
146
|
+
An access token that can be provided to a Spotify Web Player application.
|
|
147
|
+
"""
|
|
148
|
+
return self._AccessToken
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def ClientId(self) -> str:
|
|
153
|
+
"""
|
|
154
|
+
The unique identifier of the application.
|
|
155
|
+
"""
|
|
156
|
+
return self._ClientId
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def ExpiresAt(self) -> int:
|
|
161
|
+
"""
|
|
162
|
+
DateTime (in epoch seconds) that the authorization token will expire.
|
|
163
|
+
"""
|
|
164
|
+
return self._ExpiresAt
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def ExpireDateTimeUtc(self) -> datetime:
|
|
169
|
+
"""
|
|
170
|
+
DateTime (in UTC format) that the authorization token will expire.
|
|
171
|
+
"""
|
|
172
|
+
return self._ExpireDateTimeUtc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def ExpiresIn(self) -> int:
|
|
177
|
+
"""
|
|
178
|
+
The time period (in seconds) for which the access token is valid.
|
|
179
|
+
"""
|
|
180
|
+
return self._ExpiresIn
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def HeaderKey(self) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Returns a string containing the header key to assign the authorization token value to.
|
|
187
|
+
This will always return 'Authorization'.
|
|
188
|
+
"""
|
|
189
|
+
return 'Authorization'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def HeaderValue(self) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Returns the value portion of the authorization header, in the form of 'Bearer {token}'.
|
|
196
|
+
|
|
197
|
+
Example: `Bearer {token value ...}'
|
|
198
|
+
"""
|
|
199
|
+
return 'Bearer {token}'.format(token=self.AccessToken)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def IsExpired(self) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Returns true if the token has expired; otherwise, False if not expired.
|
|
206
|
+
"""
|
|
207
|
+
if self._ExpiresAt is not None:
|
|
208
|
+
nowsecs:int = int(time.time())
|
|
209
|
+
# subtract 30 seconds in case we are right at the edge of expiring.
|
|
210
|
+
if (nowsecs + 30) > self._ExpiresAt:
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def ProfileId(self) -> str:
|
|
217
|
+
"""
|
|
218
|
+
Profile identifier used when loading / storing the token to disk.
|
|
219
|
+
"""
|
|
220
|
+
return self._ProfileId
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _LoadCookieCredentials(self) -> dict:
|
|
224
|
+
"""
|
|
225
|
+
Loads a token from the token storage file for the ProviderId / ClientId key.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
A token dictionary, if one was found in the token storage file for the
|
|
229
|
+
specified ProviderId and ClientId key; otherwise, null.
|
|
230
|
+
"""
|
|
231
|
+
apiMethodName:str = '_LoadCookieCredentials'
|
|
232
|
+
apiMethodParms:SIMethodParmListContext = None
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
|
|
236
|
+
# trace.
|
|
237
|
+
apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
|
|
238
|
+
_logsi.LogMethodParmList(SILevel.Verbose, "Loading Spotify Web Player cookie credentials from token storage", apiMethodParms)
|
|
239
|
+
|
|
240
|
+
# formulate token storage key.
|
|
241
|
+
tokenKey:str = f'{self._TokenProviderId}/{self._ClientId}/{self._ProfileId}'
|
|
242
|
+
_logsi.LogVerbose("Cookie credentials storage key: \"%s\"" % (tokenKey))
|
|
243
|
+
_logsi.LogVerbose("Cookie credentials storage file path: \"%s\"" % (self._TokenStoragePath))
|
|
244
|
+
|
|
245
|
+
# does the token storage file exist?
|
|
246
|
+
if os.path.exists(self._TokenStoragePath):
|
|
247
|
+
|
|
248
|
+
# open the token storage file, and load it's contents.
|
|
249
|
+
_logsi.LogVerbose("Opening cookie credentials token storage file")
|
|
250
|
+
with open(self._TokenStoragePath, 'r') as f:
|
|
251
|
+
|
|
252
|
+
tokens = json.load(f)
|
|
253
|
+
|
|
254
|
+
# if token key exists then load the token.
|
|
255
|
+
if tokenKey in tokens:
|
|
256
|
+
_logsi.LogDictionary(SILevel.Verbose, "Cookie credentials were loaded from token storage file for provider: \"%s\"" % (tokenKey), tokens[tokenKey], prettyPrint=True)
|
|
257
|
+
|
|
258
|
+
# parse token for cookie data parameters.
|
|
259
|
+
token:dict = tokens[tokenKey]
|
|
260
|
+
self._sp_dc = token.get("sp_dc", None)
|
|
261
|
+
self._sp_key = token.get("sp_key", None)
|
|
262
|
+
|
|
263
|
+
# validation.
|
|
264
|
+
if (self._sp_dc is None):
|
|
265
|
+
raise SpotifyApiError("Cookie credentials storage key \"%s\" did not contain an \"sp_dc\" key value" % (tokenKey), None, logsi=_logsi)
|
|
266
|
+
if (self._sp_key is None):
|
|
267
|
+
raise SpotifyApiError("Cookie credentials storage key \"%s\" did not contain an \"sp_key\" key value" % (tokenKey), None, logsi=_logsi)
|
|
268
|
+
|
|
269
|
+
# return token to caller.
|
|
270
|
+
return tokens[tokenKey]
|
|
271
|
+
|
|
272
|
+
# if we make it here, then it denotes that a token was not found
|
|
273
|
+
# for the specified key.
|
|
274
|
+
raise SpotifyApiError("Spotify Web Player Cookie credentials were not found for user: \"%s\"" % (self._ProfileId), None, logsi=_logsi)
|
|
275
|
+
|
|
276
|
+
except SpotifyApiError: raise # pass handled exceptions on thru
|
|
277
|
+
except Exception as ex:
|
|
278
|
+
|
|
279
|
+
# trace.
|
|
280
|
+
raise SpotifyApiError("Could not load Spotify Web Player cookie credentials", ex, logsi=_logsi)
|
|
281
|
+
|
|
282
|
+
finally:
|
|
283
|
+
|
|
284
|
+
# trace.
|
|
285
|
+
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def GetAccessTokenFromCookieCredentials(self) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Get Spotify Web Player access token from stored Spotify Web Player cookie
|
|
291
|
+
credentials.
|
|
292
|
+
|
|
293
|
+
This will create a new session to the "https://open.spotify.com" url, passing
|
|
294
|
+
it the stored cookie credentials.
|
|
295
|
+
"""
|
|
296
|
+
apiMethodName:str = 'GetAccessTokenFromCookieCredentials'
|
|
297
|
+
apiMethodParms:SIMethodParmListContext = None
|
|
298
|
+
tracePrefix:str = 'SpotifyWebPlayerAccessToken exchange'
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
|
|
302
|
+
# trace.
|
|
303
|
+
apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
|
|
304
|
+
apiMethodParms.AppendKeyValue("sp_dc", self._sp_dc)
|
|
305
|
+
apiMethodParms.AppendKeyValue("sp_key", self._sp_key)
|
|
306
|
+
_logsi.LogMethodParmList(SILevel.Verbose, "Exchange Spotify Web Player cookie credentials for OAuth2 authorization access token", apiMethodParms)
|
|
307
|
+
|
|
308
|
+
# set initial TOTP request reason to "transport".
|
|
309
|
+
totpReason:str = "transport"
|
|
310
|
+
|
|
311
|
+
# request retry loop for failed requests that are temporary in nature (504 Gateway Timeout, etc).
|
|
312
|
+
loopTotalDelay:float = 0
|
|
313
|
+
LOOP_DELAY:float = 0.200
|
|
314
|
+
LOOP_TIMEOUT:float = 1.000
|
|
315
|
+
LOOP_TOTP_RETRY_CNT:int = 0
|
|
316
|
+
while True:
|
|
317
|
+
|
|
318
|
+
# get our current utc timestamp (current utc date, in epoch seconds).
|
|
319
|
+
totpTimestamp:int = math.floor(GetUnixTimestampMSFromUtcNow() / 1000)
|
|
320
|
+
|
|
321
|
+
# create a TOTP processing instance.
|
|
322
|
+
totpObj:TOTP = None
|
|
323
|
+
serverTimeSeconds:int = 0
|
|
324
|
+
totpObj, serverTimeSeconds = self.GetTotpObject()
|
|
325
|
+
|
|
326
|
+
# generate the Time-based One Time Password (TOTP) value.
|
|
327
|
+
totpValue:str = totpObj.at(serverTimeSeconds)
|
|
328
|
+
_logsi.LogString(SILevel.Verbose, "Spotify TOTP (Time-based One Time Password) value (len=%d)" % (len(totpValue)), totpValue)
|
|
329
|
+
|
|
330
|
+
# create a session, using the specified header and cookie data.
|
|
331
|
+
session = requests.Session()
|
|
332
|
+
reqUrl:str = SPOTIFY_WEBUI_URL_GET_ACCESS_TOKEN
|
|
333
|
+
reqHeaders = {'user-agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"}
|
|
334
|
+
reqCookies = {
|
|
335
|
+
"sp_dc": self._sp_dc,
|
|
336
|
+
"sp_key": self._sp_key, # sp_key is not required if totp value present.
|
|
337
|
+
}
|
|
338
|
+
reqParms={
|
|
339
|
+
# "reason": totpReason,
|
|
340
|
+
# "productType": "web_player",
|
|
341
|
+
# "totp": totpValue,
|
|
342
|
+
# "totpVer": 5,
|
|
343
|
+
# "ts": totpTimestamp,
|
|
344
|
+
"reason": totpReason,
|
|
345
|
+
"productType": "web-player",
|
|
346
|
+
"totp": totpValue,
|
|
347
|
+
"totpServer": totpValue,
|
|
348
|
+
"totpVer": 5,
|
|
349
|
+
"sTime": serverTimeSeconds,
|
|
350
|
+
"cTime": serverTimeSeconds,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
# trace.
|
|
354
|
+
_logsi.LogVerbose("Exchanging Spotify Web Player cookie credentials for OAuth2 authorization access token")
|
|
355
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http request: \"%s\" (cookies)" % (tracePrefix, reqUrl), reqCookies)
|
|
356
|
+
_logsi.LogDictionary(SILevel.Debug, "%s http request: \"%s\" (headers)" % (tracePrefix, reqUrl), reqHeaders)
|
|
357
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http request: \"%s\" (parms)" % (tracePrefix, reqUrl), reqParms)
|
|
358
|
+
|
|
359
|
+
# convert the spotify web player cookie credentials to an access token.
|
|
360
|
+
response = session.get(
|
|
361
|
+
reqUrl,
|
|
362
|
+
cookies=reqCookies,
|
|
363
|
+
headers=reqHeaders,
|
|
364
|
+
params=reqParms,
|
|
365
|
+
allow_redirects=False,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# TEST TODO - for testing retry logic.
|
|
369
|
+
# if (response.status_code == 200) and (loopTotalDelay <= 0.200):
|
|
370
|
+
# _logsi.LogWarning("TEST TODO - Testing Spotify Web API 504 status (Gateway timeout) condition ...", colorValue=SIColors.Red)
|
|
371
|
+
# response.status_code = 404
|
|
372
|
+
# response.reason = "URL Not Found"
|
|
373
|
+
# if (response.status_code == 200) and (loopTotalDelay <= 0.200):
|
|
374
|
+
# _logsi.LogWarning("TEST TODO - Testing Spotify Web API 504 status (Gateway timeout) condition ...", colorValue=SIColors.Red)
|
|
375
|
+
# response.status_code = 504
|
|
376
|
+
# response.reason = "Gateway Timeout"
|
|
377
|
+
|
|
378
|
+
# check for errors that are temporary in nature; for these errors, we will retry the
|
|
379
|
+
# request for a specified number of tries with a small wait period in between.
|
|
380
|
+
if (response.status_code == 504):
|
|
381
|
+
|
|
382
|
+
# only retry so many times before we give up.
|
|
383
|
+
if (loopTotalDelay >= LOOP_TIMEOUT):
|
|
384
|
+
raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_TIMEOUT % (loopTotalDelay), None, logsi=_logsi)
|
|
385
|
+
|
|
386
|
+
# trace.
|
|
387
|
+
_logsi.LogVerbose(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_RESPONSE_STATUS % (response.status_code, response.reason), colorValue=SIColors.Red)
|
|
388
|
+
|
|
389
|
+
# wait just a bit between requests.
|
|
390
|
+
_logsi.LogVerbose(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_REQUEST_DELAY % (LOOP_DELAY))
|
|
391
|
+
time.sleep(LOOP_DELAY)
|
|
392
|
+
loopTotalDelay = loopTotalDelay + LOOP_DELAY
|
|
393
|
+
|
|
394
|
+
# process next while iteration.
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
# trace.
|
|
398
|
+
if _logsi.IsOn(SILevel.Debug):
|
|
399
|
+
_logsi.LogObject(SILevel.Debug, "%s http response object - type=\"%s\", module=\"%s\"" % (tracePrefix, type(response).__name__, type(response).__module__), response)
|
|
400
|
+
_logsi.LogObject(SILevel.Debug, "%s http response [%s-%s] (response)" % (tracePrefix, response.status_code, response.reason), response)
|
|
401
|
+
if (response.headers):
|
|
402
|
+
_logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s] (headers)" % (tracePrefix, response.status_code, response.reason), response.headers.items())
|
|
403
|
+
|
|
404
|
+
# raise exception if we could not get the access token, or if a redirect occured.
|
|
405
|
+
# the redirect usually indicates the sp_dc and sp_key are expired, and it's redirecting to a login page.
|
|
406
|
+
respHdrLocation:str = response.headers.get("location", "")
|
|
407
|
+
if ((response.status_code == 302) and (respHdrLocation.find("_authfailed=1") != -1)):
|
|
408
|
+
raise SpotifyApiError("Token exchange request failed with status 302 and \"authfailed\" indicator. sp_dc and sp_key could be expired; please update values in configuration options.", None, logsi=_logsi)
|
|
409
|
+
|
|
410
|
+
# if successful request, then process response; otherwise, it's an exception.
|
|
411
|
+
if (response.status_code == 200):
|
|
412
|
+
_logsi.LogVerbose("Token exchange request was successful within %f seconds from initial request; processing results" % (loopTotalDelay))
|
|
413
|
+
else:
|
|
414
|
+
raise SpotifyApiError("Token exchange request failed: %s - %s" % (response.status_code, response.reason), None, logsi=_logsi)
|
|
415
|
+
|
|
416
|
+
# load request response.
|
|
417
|
+
data = response.content.decode('utf-8')
|
|
418
|
+
responseData = json.loads(data)
|
|
419
|
+
|
|
420
|
+
# trace.
|
|
421
|
+
if _logsi.IsOn(SILevel.Verbose):
|
|
422
|
+
if isinstance(responseData, dict):
|
|
423
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http response [%s-%s] (json dict)" % (tracePrefix, response.status_code, response.reason), responseData, prettyPrint=True)
|
|
424
|
+
elif isinstance(responseData, list):
|
|
425
|
+
_logsi.LogArray(SILevel.Verbose, "%s http response [%s-%s] (json array)" % (tracePrefix, response.status_code, response.reason), responseData)
|
|
426
|
+
else:
|
|
427
|
+
_logsi.LogObject(SILevel.Verbose, "%s http response [%s-%s] (json object)" % (tracePrefix, response.status_code, response.reason), responseData)
|
|
428
|
+
|
|
429
|
+
# process the access token information.
|
|
430
|
+
self._AccessToken = responseData.get('accessToken', None)
|
|
431
|
+
self._ExpiresAt = int(responseData.get('accessTokenExpirationTimestampMs', 0)) // 1000
|
|
432
|
+
self._ExpiresIn = self._ExpiresAt - int(time.time())
|
|
433
|
+
self._IsAnonymous = bool(responseData.get('isAnonymous', False))
|
|
434
|
+
|
|
435
|
+
# set non-JSON response properties.
|
|
436
|
+
# calculate expire time based on ExpiresAt (epoch) seconds.
|
|
437
|
+
self._ExpireDateTimeUtc = datetime.utcfromtimestamp(self._ExpiresAt)
|
|
438
|
+
|
|
439
|
+
# the following can be used if the `datetime.utcfromtimestamp` is ever removed from python:
|
|
440
|
+
# unix_epoch = datetime(1970, 1, 1)
|
|
441
|
+
# dtUtcNow:datetime = datetime.now(timezone.utc)
|
|
442
|
+
# self._ExpireDateTimeUtc = dtUtcNow + timedelta(seconds=self._ExpiresIn)
|
|
443
|
+
|
|
444
|
+
# at this point we have an authorization token, but we don't know if it's really valid!
|
|
445
|
+
# we will verify token is valid by requesting the current users profile.
|
|
446
|
+
# if user profile request is valid, then we know it's good; otherwise, we will
|
|
447
|
+
# repeat the whole process uing a totp reason of "init".
|
|
448
|
+
# sometimes it takes a few tries to get a valid token (we limit it to 5 tries).
|
|
449
|
+
|
|
450
|
+
# verify token is valid by retrieving current users profile.
|
|
451
|
+
_logsi.LogVerbose("Verifying Spotify Web Player authorization token is valid")
|
|
452
|
+
|
|
453
|
+
# request current users profile to check token validity.
|
|
454
|
+
reqUrl:str = SPOTIFY_WEBAPI_URL_BASE + "/me"
|
|
455
|
+
reqHeaders = {
|
|
456
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
|
|
457
|
+
"Authorization":self.HeaderValue
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# trace.
|
|
461
|
+
_logsi.LogVerbose("Requesting Spotify Current Users Profile to check Spotify Web Player authorization access token validity")
|
|
462
|
+
_logsi.LogDictionary(SILevel.Debug, "%s http request: \"%s\" (headers)" % (tracePrefix, reqUrl), reqHeaders)
|
|
463
|
+
|
|
464
|
+
# execute spotify web api request.
|
|
465
|
+
response = session.get(reqUrl, headers=reqHeaders)
|
|
466
|
+
|
|
467
|
+
# trace.
|
|
468
|
+
if _logsi.IsOn(SILevel.Debug):
|
|
469
|
+
_logsi.LogObject(SILevel.Debug, "%s http response object - type=\"%s\", module=\"%s\"" % (tracePrefix, type(response).__name__, type(response).__module__), response)
|
|
470
|
+
_logsi.LogObject(SILevel.Debug, "%s http response [%s-%s] (response)" % (tracePrefix, response.status_code, response.reason), response)
|
|
471
|
+
if (response.headers):
|
|
472
|
+
_logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s] (headers)" % (tracePrefix, response.status_code, response.reason), response.headers.items())
|
|
473
|
+
|
|
474
|
+
# was request successful?
|
|
475
|
+
if (response.status_code == 200):
|
|
476
|
+
|
|
477
|
+
# yes - break out of while loop; we have a valid token!
|
|
478
|
+
_logsi.LogVerbose("Spotify Web Player authorization token is valid (TOTP Retry Count=%s)" % (LOOP_TOTP_RETRY_CNT), colorValue=SIColors.Gold)
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
else:
|
|
482
|
+
|
|
483
|
+
# no - set TOTP request reason to "init" for token retry requests.
|
|
484
|
+
totpReason:str = "init"
|
|
485
|
+
_logsi.LogVerbose("Spotify Web Player authorization token is NOT valid; retrying (TOTP Retry Count=%s) ..." % (LOOP_TOTP_RETRY_CNT), colorValue=SIColors.Red)
|
|
486
|
+
|
|
487
|
+
# bump TOTP retry count; break out of the loop if limit is exceeded.
|
|
488
|
+
LOOP_TOTP_RETRY_CNT += 1
|
|
489
|
+
if (LOOP_TOTP_RETRY_CNT > 5):
|
|
490
|
+
raise SpotifyApiError("Spotify Web Player authorization token could not be retrieved!", None, logsi=_logsi)
|
|
491
|
+
|
|
492
|
+
# trace.
|
|
493
|
+
_logsi.LogObject(SILevel.Verbose, "Spotify Web Player access token object", self, excludeNonPublic=True)
|
|
494
|
+
|
|
495
|
+
except SpotifyApiError: raise # pass handled exceptions on thru
|
|
496
|
+
except requests.TooManyRedirects as ex:
|
|
497
|
+
|
|
498
|
+
# trace.
|
|
499
|
+
raise SpotifyApiError("Could not get Spotify Web Player access token; sp_dc could be expired - please update value", ex, logsi=_logsi)
|
|
500
|
+
|
|
501
|
+
except Exception as ex:
|
|
502
|
+
|
|
503
|
+
# trace.
|
|
504
|
+
raise SpotifyApiError("Could not get Spotify Web Player access token", ex, logsi=_logsi)
|
|
505
|
+
|
|
506
|
+
finally:
|
|
507
|
+
|
|
508
|
+
# trace.
|
|
509
|
+
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def GetTotpObject(self) -> tuple[TOTP, int]:
|
|
513
|
+
"""
|
|
514
|
+
Creates a TOTP (Time-based One Time Password) object that can be used to generate a time-based
|
|
515
|
+
one time password value for the Spotify `get_access_token` request.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
totpObj (TOTP):
|
|
519
|
+
A TOTP object, which can be used to generate the One Time Password value.
|
|
520
|
+
serverTimeSeconds (int):
|
|
521
|
+
Spotify server time value (in utc epoch seconds).
|
|
522
|
+
|
|
523
|
+
TOTP authentication is a 2-factor verification method that uses the time as a variable.
|
|
524
|
+
|
|
525
|
+
TOTP codes are valid for a short period (typically 30 or 60 seconds) to enhance
|
|
526
|
+
security by preventing replay attacks.
|
|
527
|
+
|
|
528
|
+
Here's how a TOTP algorithm works:
|
|
529
|
+
|
|
530
|
+
1. A user wants to log into a TOTP 2FA protected application or website.
|
|
531
|
+
For the OTP authentication to run, the user and the TOTP server need to initially share
|
|
532
|
+
a static parameter (a secret key).
|
|
533
|
+
|
|
534
|
+
2. When the client logs into the protected website, they have to confirm they possess the secret key.
|
|
535
|
+
So their TOTP token merges the seed and the current timestep and generates a HASH value by running
|
|
536
|
+
a predetermined HASH function. This value essentially is the OTP code the user sees on the token.
|
|
537
|
+
|
|
538
|
+
3. Since the secret key, the HASH function, and the timestep are the same for both parties, the server
|
|
539
|
+
makes the same computation as the user's OTP generator.
|
|
540
|
+
|
|
541
|
+
4. The user enters the OTP and if it is identical to the server's value, the access is granted.
|
|
542
|
+
If the results of the calculations aren't identical, the access is, naturally, denied.
|
|
543
|
+
"""
|
|
544
|
+
apiMethodName:str = 'GetTotpObject'
|
|
545
|
+
apiMethodParms:SIMethodParmListContext = None
|
|
546
|
+
tracePrefix:str = 'SpotifyWebPlayerAccessToken TOTP Object'
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
|
|
550
|
+
# trace.
|
|
551
|
+
apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
|
|
552
|
+
_logsi.LogMethodParmList(SILevel.Verbose, "Generating Spotify TOTP (Time-based One Time Password) value", apiMethodParms)
|
|
553
|
+
|
|
554
|
+
# characters allowed in the generated base32 secret string value.
|
|
555
|
+
secretSauce:str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
556
|
+
|
|
557
|
+
# perform a bitwise XOR (^) on each cipherbyte.
|
|
558
|
+
secretCipherBytesRaw:list = [12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54]
|
|
559
|
+
_logsi.LogArray(SILevel.Debug, "%s - secretCipherBytesRaw (len=%d)" % (tracePrefix, len(secretCipherBytesRaw)), secretCipherBytesRaw)
|
|
560
|
+
secretCipherBytes:list = [
|
|
561
|
+
elm ^ ((idx % 33) + 9)
|
|
562
|
+
for idx, elm in enumerate(secretCipherBytesRaw)
|
|
563
|
+
]
|
|
564
|
+
_logsi.LogArray(SILevel.Debug, "%s - secretCipherBytes (len=%d)" % (tracePrefix, len(secretCipherBytes)), secretCipherBytes)
|
|
565
|
+
|
|
566
|
+
# convert secret cipher bytes into a string, then encode as UTF-8.
|
|
567
|
+
secretCipherString:str = "".join(str(num) for num in secretCipherBytes)
|
|
568
|
+
_logsi.LogString(SILevel.Debug, "%s - secretCipherString (len=%d)" % (tracePrefix, len(secretCipherString)), secretCipherString)
|
|
569
|
+
secretCipherBytesEncoded:bytes = secretCipherString.encode("utf-8")
|
|
570
|
+
_logsi.LogBinary(SILevel.Debug, "%s - secretCipherBytesEncoded (len=%d)" % (tracePrefix, len(secretCipherBytesEncoded)), secretCipherBytesEncoded)
|
|
571
|
+
|
|
572
|
+
# convert each byte to hexadecimal string.
|
|
573
|
+
secretCipherBytesHex:str = "".join(["{:02x}".format(byte) for byte in secretCipherBytesEncoded])
|
|
574
|
+
_logsi.LogString(SILevel.Debug, "%s - secretCipherBytesHex (len=%d)" % (tracePrefix, len(secretCipherBytesHex)), secretCipherBytesHex)
|
|
575
|
+
|
|
576
|
+
# clean the result using the CleanBuffer function.
|
|
577
|
+
secretBytes:bytes = self.CleanBuffer(secretCipherBytesHex)
|
|
578
|
+
_logsi.LogBinary(SILevel.Debug, "%s - secretBytes (len=%d)" % (tracePrefix, len(secretBytes)), secretBytes)
|
|
579
|
+
|
|
580
|
+
# convert bytes to base32 string value.
|
|
581
|
+
secretBase32:str = self.Base32FromBytes(secretBytes, secretSauce)
|
|
582
|
+
_logsi.LogString(SILevel.Debug, "%s - secretBase32 (len=%d)" % (tracePrefix, len(secretBase32)), secretBase32)
|
|
583
|
+
|
|
584
|
+
# create a session, using the specified header data.
|
|
585
|
+
session = requests.Session()
|
|
586
|
+
reqUrl:str = SPOTIFY_WEBUI_URL_GET_SERVER_TIME
|
|
587
|
+
reqHeaders = {'user-agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"}
|
|
588
|
+
|
|
589
|
+
# trace.
|
|
590
|
+
_logsi.LogVerbose("Retrieving Spotify TOTP server time value")
|
|
591
|
+
_logsi.LogDictionary(SILevel.Debug, "%s http request: \"%s\" (headers)" % (tracePrefix, SPOTIFY_WEBUI_URL_GET_SERVER_TIME), reqHeaders)
|
|
592
|
+
|
|
593
|
+
# request retry loop for failed requests that are temporary in nature (504 Gateway Timeout, etc).
|
|
594
|
+
loopTotalDelay:float = 0
|
|
595
|
+
LOOP_DELAY:float = 0.200
|
|
596
|
+
LOOP_TIMEOUT:float = 1.000
|
|
597
|
+
while True:
|
|
598
|
+
|
|
599
|
+
# get Spotify server time value.
|
|
600
|
+
response = session.get(
|
|
601
|
+
reqUrl,
|
|
602
|
+
headers=reqHeaders,
|
|
603
|
+
allow_redirects=False,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# check for errors that are temporary in nature; for these errors, we will retry the
|
|
607
|
+
# request for a specified number of tries with a small wait period in between.
|
|
608
|
+
if (response.status_code == 504):
|
|
609
|
+
|
|
610
|
+
# only retry so many times before we give up.
|
|
611
|
+
if (loopTotalDelay >= LOOP_TIMEOUT):
|
|
612
|
+
raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_TIMEOUT % (loopTotalDelay), None, logsi=_logsi)
|
|
613
|
+
|
|
614
|
+
# trace.
|
|
615
|
+
_logsi.LogVerbose(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_RESPONSE_STATUS % (response.status_code, response.reason), colorValue=SIColors.Red)
|
|
616
|
+
|
|
617
|
+
# wait just a bit between requests.
|
|
618
|
+
_logsi.LogVerbose(SAAppMessages.MSG_SPOTIFY_WEB_API_RETRY_REQUEST_DELAY % (LOOP_DELAY))
|
|
619
|
+
time.sleep(LOOP_DELAY)
|
|
620
|
+
loopTotalDelay = loopTotalDelay + LOOP_DELAY
|
|
621
|
+
|
|
622
|
+
else:
|
|
623
|
+
|
|
624
|
+
# otherwise, break out of retry loop and process response.
|
|
625
|
+
break
|
|
626
|
+
|
|
627
|
+
# trace.
|
|
628
|
+
if _logsi.IsOn(SILevel.Debug):
|
|
629
|
+
_logsi.LogObject(SILevel.Debug, "%s http response object - type=\"%s\", module=\"%s\"" % (tracePrefix, type(response).__name__, type(response).__module__), response)
|
|
630
|
+
_logsi.LogObject(SILevel.Debug, "%s http response [%s-%s] (response)" % (tracePrefix, response.status_code, response.reason), response)
|
|
631
|
+
if (response.headers):
|
|
632
|
+
_logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s] (headers)" % (tracePrefix, response.status_code, response.reason), response.headers.items())
|
|
633
|
+
|
|
634
|
+
# if successful request, then process response; otherwise, it's an exception.
|
|
635
|
+
if (response.status_code == 200):
|
|
636
|
+
_logsi.LogVerbose("Spotify TOTP server time request was successful within %f seconds from initial request; processing results" % (loopTotalDelay))
|
|
637
|
+
else:
|
|
638
|
+
raise SpotifyApiError("Spotify TOTP Server Time request failed: %s - %s" % (response.status_code, response.reason), None, logsi=_logsi)
|
|
639
|
+
|
|
640
|
+
# load request response.
|
|
641
|
+
data = response.content.decode('utf-8')
|
|
642
|
+
responseData = json.loads(data)
|
|
643
|
+
|
|
644
|
+
# trace.
|
|
645
|
+
if _logsi.IsOn(SILevel.Verbose):
|
|
646
|
+
if isinstance(responseData, dict):
|
|
647
|
+
_logsi.LogDictionary(SILevel.Verbose, "%s http response [%s-%s] (json dict)" % (tracePrefix, response.status_code, response.reason), responseData, prettyPrint=True)
|
|
648
|
+
elif isinstance(responseData, list):
|
|
649
|
+
_logsi.LogArray(SILevel.Verbose, "%s http response [%s-%s] (json array)" % (tracePrefix, response.status_code, response.reason), responseData)
|
|
650
|
+
else:
|
|
651
|
+
_logsi.LogObject(SILevel.Verbose, "%s http response [%s-%s] (json object)" % (tracePrefix, response.status_code, response.reason), responseData)
|
|
652
|
+
|
|
653
|
+
# parse server time value; if not found, then it's an error.
|
|
654
|
+
serverTimeSeconds:int = responseData.get("serverTime", None);
|
|
655
|
+
if (serverTimeSeconds == None):
|
|
656
|
+
raise SpotifyApiError("Spotify TOTP server time response was not recognized: \"%s\"" % (str(data)), None, logsi=_logsi)
|
|
657
|
+
|
|
658
|
+
# trace.
|
|
659
|
+
_logsi.LogVerbose("Spotify TOTP server time value = \"%s\"" % (serverTimeSeconds))
|
|
660
|
+
|
|
661
|
+
# create TOTP instance.
|
|
662
|
+
totp:TOTP = TOTP(
|
|
663
|
+
secretBase32,
|
|
664
|
+
digest="SHA1",
|
|
665
|
+
digits=6,
|
|
666
|
+
interval=30,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# return TOTP related variables to caller.
|
|
670
|
+
return totp, serverTimeSeconds
|
|
671
|
+
|
|
672
|
+
except Exception as ex:
|
|
673
|
+
|
|
674
|
+
# trace.
|
|
675
|
+
raise SpotifyApiError("Could not get Spotify Web Player TOTP value", ex, logsi=_logsi)
|
|
676
|
+
|
|
677
|
+
finally:
|
|
678
|
+
|
|
679
|
+
# trace.
|
|
680
|
+
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def Base32FromBytes(self, inputBytes:bytes, secretSauce:str) -> str:
|
|
684
|
+
"""
|
|
685
|
+
Converts an array of bytes to a base32 string value.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
inputBytes (bytearray):
|
|
689
|
+
Array of bytes to convert to Base32.
|
|
690
|
+
secretSauce (str):
|
|
691
|
+
Characters allowed in the generated base32 string value.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
A generated base32 string value.
|
|
695
|
+
"""
|
|
696
|
+
|
|
697
|
+
t:int = 0 # This will store the number of bits processed
|
|
698
|
+
n:int = 0 # This will store the accumulated bits
|
|
699
|
+
result:str = "" # This will store the resulting base32 string
|
|
700
|
+
|
|
701
|
+
for byte in inputBytes:
|
|
702
|
+
|
|
703
|
+
# shift accumulated bits to the left and add the current byte.
|
|
704
|
+
n = (n << 8) | byte
|
|
705
|
+
t += 8
|
|
706
|
+
|
|
707
|
+
# while we have more than 5 bits, we process them.
|
|
708
|
+
while t >= 5:
|
|
709
|
+
# extract 5 bits and append the corresponding character from secret sauce.
|
|
710
|
+
result += secretSauce[(n >> (t - 5)) & 31]
|
|
711
|
+
t -= 5
|
|
712
|
+
|
|
713
|
+
# if there are any remaining bits, process them.
|
|
714
|
+
if t > 0:
|
|
715
|
+
result += secretSauce[(n << (5 - t)) & 31]
|
|
716
|
+
|
|
717
|
+
# return result to caller.
|
|
718
|
+
return result
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def CleanBuffer(self, value:str) -> bytes:
|
|
722
|
+
"""
|
|
723
|
+
Converts a displayable hex string value to a bytes object.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
value (str):
|
|
727
|
+
Input value in displayable hex format (e.g. "0140FF").
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
A bytes object that contains the converted `value` contents.
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
# create a list of zeroes of length half the size of input value.
|
|
734
|
+
result:bytes = [0] * (len(value) // 2)
|
|
735
|
+
|
|
736
|
+
# convert the hexadecimal pairs to integers.
|
|
737
|
+
for idx in range(0, len(value), 2):
|
|
738
|
+
result[idx // 2] = int(value[idx:idx+2], 16)
|
|
739
|
+
|
|
740
|
+
# return result to caller.
|
|
741
|
+
return bytes(result)
|
|
742
|
+
|
|
743
|
+
|