spotify-monitor 2.0rc2__py3-none-any.whl → 2.1__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.
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.dist-info}/METADATA +63 -7
- spotify_monitor-2.1.dist-info/RECORD +7 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.dist-info}/WHEEL +1 -1
- spotify_monitor.py +923 -100
- spotify_monitor-2.0rc2.dist-info/RECORD +0 -7
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.dist-info}/entry_points.txt +0 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.dist-info}/licenses/LICENSE +0 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.dist-info}/top_level.txt +0 -0
spotify_monitor.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
|
|
4
|
-
v2.
|
|
4
|
+
v2.1
|
|
5
5
|
|
|
6
6
|
Tool implementing real-time tracking of Spotify friends music activity:
|
|
7
7
|
https://github.com/misiektoja/spotify_monitor/
|
|
@@ -11,20 +11,30 @@ Python pip3 requirements:
|
|
|
11
11
|
requests
|
|
12
12
|
python-dateutil
|
|
13
13
|
urllib3
|
|
14
|
-
pyotp
|
|
14
|
+
pyotp (needed when the token source is set to cookie)
|
|
15
15
|
python-dotenv (optional)
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
VERSION = "2.
|
|
18
|
+
VERSION = "2.1"
|
|
19
19
|
|
|
20
20
|
# ---------------------------
|
|
21
21
|
# CONFIGURATION SECTION START
|
|
22
22
|
# ---------------------------
|
|
23
23
|
|
|
24
24
|
CONFIG_BLOCK = """
|
|
25
|
+
# Select the method used to obtain the Spotify access token
|
|
26
|
+
# Available options:
|
|
27
|
+
# cookie - uses the sp_dc cookie to retrieve a token via the Spotify web endpoint (recommended)
|
|
28
|
+
# client - uses captured credentials from the Spotify desktop client and a Protobuf-based login flow (for advanced users)
|
|
29
|
+
TOKEN_SOURCE = "cookie"
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# The section below is used when the token source is set to 'cookie'
|
|
34
|
+
# (to configure the alternative 'client' method, see the section at the end of this config block)
|
|
35
|
+
#
|
|
25
36
|
# Log in to Spotify web client (https://open.spotify.com/) and retrieve your sp_dc cookie
|
|
26
37
|
# Use your web browser's dev console or "Cookie-Editor" by cgagnier to extract it easily: https://cookie-editor.com/
|
|
27
|
-
# The sp_dc cookie is typically valid for up to 2 weeks
|
|
28
38
|
#
|
|
29
39
|
# Provide the SP_DC_COOKIE secret using one of the following methods:
|
|
30
40
|
# - Pass it at runtime with -u / --spotify-dc-cookie
|
|
@@ -34,6 +44,8 @@ CONFIG_BLOCK = """
|
|
|
34
44
|
# - Hard-code it in the code or config file
|
|
35
45
|
SP_DC_COOKIE = "your_sp_dc_cookie_value"
|
|
36
46
|
|
|
47
|
+
# ------------------------------------------------
|
|
48
|
+
|
|
37
49
|
# SMTP settings for sending email notifications
|
|
38
50
|
# If left as-is, no notifications will be sent
|
|
39
51
|
#
|
|
@@ -199,11 +211,93 @@ CLEAR_SCREEN = True
|
|
|
199
211
|
# Value added/subtracted via signal handlers to adjust inactivity timeout (SPOTIFY_INACTIVITY_CHECK); in seconds
|
|
200
212
|
SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE = 30 # 30 seconds
|
|
201
213
|
|
|
202
|
-
# Maximum number of attempts to get a valid access token in a single run of the
|
|
214
|
+
# Maximum number of attempts to get a valid access token in a single run of the spotify_get_access_token_from_sp_dc() function
|
|
215
|
+
# Used only when the token source is set to 'cookie'
|
|
203
216
|
TOKEN_MAX_RETRIES = 10
|
|
204
217
|
|
|
205
218
|
# Interval between access token retry attempts; in seconds
|
|
219
|
+
# Used only when the token source is set to 'cookie'
|
|
206
220
|
TOKEN_RETRY_TIMEOUT = 0.5 # 0.5 second
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------
|
|
223
|
+
# The section below is used when the token source is set to 'client'
|
|
224
|
+
#
|
|
225
|
+
# To extract device_id, system_id, user_uri_id and refresh_token from binary login request Protobuf file:
|
|
226
|
+
#
|
|
227
|
+
# - run an intercepting proxy of your choice (like Proxyman)
|
|
228
|
+
# - launch the Spotify desktop client and look for requests to: https://login{n}.spotify.com/v3/login
|
|
229
|
+
# note: the 'login' part is suffixed with one or more digits
|
|
230
|
+
# - export the login request body (a binary Protobuf payload) to a file
|
|
231
|
+
# (e.g. in Proxyman: right click the request -> Export -> Request Body -> Save File -> <login-request-body-file>)
|
|
232
|
+
#
|
|
233
|
+
# Can also be set using the -w flag
|
|
234
|
+
LOGIN_REQUEST_BODY_FILE = ""
|
|
235
|
+
|
|
236
|
+
# Alternatively, set the configuration options below manually
|
|
237
|
+
#
|
|
238
|
+
# For the binary login request Protobuf body, if your proxy supports Protobuf decoding, extract:
|
|
239
|
+
# - DEVICE_ID
|
|
240
|
+
# - SYSTEM_ID
|
|
241
|
+
# - USER_URI_ID
|
|
242
|
+
# - REFRESH_TOKEN
|
|
243
|
+
# and assign them to respective configuration options
|
|
244
|
+
#
|
|
245
|
+
# Alternatively, you can use protoc tool (part of Protobuf pip package) to decode the binary:
|
|
246
|
+
# protoc --decode_raw < <path-to-login-request-body-file>
|
|
247
|
+
#
|
|
248
|
+
# If you decided to set the configuration options manually, it is recommended to provide the REFRESH_TOKEN
|
|
249
|
+
# secret using one of the following methods:
|
|
250
|
+
# - Set it as an environment variable (e.g. export REFRESH_TOKEN=...)
|
|
251
|
+
# - Add it to ".env" file (REFRESH_TOKEN=...) for persistent use
|
|
252
|
+
# Fallback:
|
|
253
|
+
# - Hard-code it in the code or config file
|
|
254
|
+
DEVICE_ID = "your_spotify_app_device_id"
|
|
255
|
+
SYSTEM_ID = "your_spotify_app_system_id"
|
|
256
|
+
USER_URI_ID = "your_spotify_user_uri_id"
|
|
257
|
+
REFRESH_TOKEN = "your_spotify_app_refresh_token"
|
|
258
|
+
|
|
259
|
+
# Default values below are typically fine - only modify if necessary
|
|
260
|
+
|
|
261
|
+
# Spotify login URL
|
|
262
|
+
LOGIN_URL = "https://login5.spotify.com/v3/login"
|
|
263
|
+
|
|
264
|
+
# Spotify client token URL
|
|
265
|
+
CLIENTTOKEN_URL = "https://clienttoken.spotify.com/v1/clienttoken"
|
|
266
|
+
|
|
267
|
+
# Optional: specify user agent manually, some examples:
|
|
268
|
+
#
|
|
269
|
+
# Spotify/126200580 Win32_x86_64/0 (PC desktop)
|
|
270
|
+
# Spotify/126400408 OSX_ARM64/OS X 15.5.0 [arm 2]
|
|
271
|
+
#
|
|
272
|
+
# Leave empty to auto-generate it randomly
|
|
273
|
+
USER_AGENT = ""
|
|
274
|
+
|
|
275
|
+
# Optional: specify app version manually (e.g. '1.2.62.580.g7e3d9a4f')
|
|
276
|
+
# Leave empty to auto-generate from USER_AGENT
|
|
277
|
+
APP_VERSION = ""
|
|
278
|
+
|
|
279
|
+
# Platform-specific values for token generation, typically leave unchanged
|
|
280
|
+
# You can also extract these from a captured client token request Protobuf file (see below)
|
|
281
|
+
CPU_ARCH = 10
|
|
282
|
+
OS_BUILD = 19045
|
|
283
|
+
PLATFORM = 2
|
|
284
|
+
OS_MAJOR = 9
|
|
285
|
+
OS_MINOR = 9
|
|
286
|
+
CLIENT_MODEL = 34404
|
|
287
|
+
|
|
288
|
+
# Optional: to extract app_version, cpu_arch, os_build, platform, os_major, os_minor and client_model from
|
|
289
|
+
# binary client token request Protobuf file:
|
|
290
|
+
#
|
|
291
|
+
# - run an intercepting proxy of your choice (like Proxyman)
|
|
292
|
+
# - launch the Spotify desktop client and look for requests to: https://clienttoken.spotify.com/v1/clienttoken
|
|
293
|
+
# (these requests are sent every time client token expires, usually every 2 weeks)
|
|
294
|
+
# - export the client token request body (a binary Protobuf payload) to a file
|
|
295
|
+
# (e.g. in Proxyman: right click the request -> Export -> Request Body -> Save File -> <clienttoken-request-body-file>)
|
|
296
|
+
#
|
|
297
|
+
# Can also be set via the -z flag
|
|
298
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = ""
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------
|
|
207
301
|
"""
|
|
208
302
|
|
|
209
303
|
# -------------------------
|
|
@@ -212,7 +306,24 @@ TOKEN_RETRY_TIMEOUT = 0.5 # 0.5 second
|
|
|
212
306
|
|
|
213
307
|
# Default dummy values so linters shut up
|
|
214
308
|
# Do not change values below - modify them in the configuration section or config file instead
|
|
309
|
+
TOKEN_SOURCE = ""
|
|
215
310
|
SP_DC_COOKIE = ""
|
|
311
|
+
LOGIN_REQUEST_BODY_FILE = ""
|
|
312
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = ""
|
|
313
|
+
LOGIN_URL = ""
|
|
314
|
+
USER_AGENT = ""
|
|
315
|
+
DEVICE_ID = ""
|
|
316
|
+
SYSTEM_ID = ""
|
|
317
|
+
USER_URI_ID = ""
|
|
318
|
+
REFRESH_TOKEN = ""
|
|
319
|
+
CLIENTTOKEN_URL = ""
|
|
320
|
+
APP_VERSION = ""
|
|
321
|
+
CPU_ARCH = 0
|
|
322
|
+
OS_BUILD = 0
|
|
323
|
+
PLATFORM = 0
|
|
324
|
+
OS_MAJOR = 0
|
|
325
|
+
OS_MINOR = 0
|
|
326
|
+
CLIENT_MODEL = 0
|
|
216
327
|
SMTP_HOST = ""
|
|
217
328
|
SMTP_PORT = 0
|
|
218
329
|
SMTP_USER = ""
|
|
@@ -264,18 +375,23 @@ exec(CONFIG_BLOCK, globals())
|
|
|
264
375
|
DEFAULT_CONFIG_FILENAME = "spotify_monitor.conf"
|
|
265
376
|
|
|
266
377
|
# List of secret keys to load from env/config
|
|
267
|
-
SECRET_KEYS = ("SP_DC_COOKIE", "SMTP_PASSWORD")
|
|
378
|
+
SECRET_KEYS = ("REFRESH_TOKEN", "SP_DC_COOKIE", "SMTP_PASSWORD")
|
|
268
379
|
|
|
269
380
|
# Strings removed from track names for generating proper Genius search URLs
|
|
270
381
|
re_search_str = r'remaster|extended|original mix|remix|original soundtrack|radio( |-)edit|\(feat\.|( \(.*version\))|( - .*version)'
|
|
271
382
|
re_replace_str = r'( - (\d*)( )*remaster$)|( - (\d*)( )*remastered( version)*( \d*)*.*$)|( \((\d*)( )*remaster\)$)|( - (\d+) - remaster$)|( - extended$)|( - extended mix$)|( - (.*); extended mix$)|( - extended version$)|( - (.*) remix$)|( - remix$)|( - remixed by .*$)|( - original mix$)|( - .*original soundtrack$)|( - .*radio( |-)edit$)|( \(feat\. .*\)$)|( \(\d+.*Remaster.*\)$)|( \(.*Version\))|( - .*version)'
|
|
272
383
|
|
|
273
|
-
# Default value for network-related timeouts in functions
|
|
384
|
+
# Default value for network-related timeouts in functions; in seconds
|
|
274
385
|
FUNCTION_TIMEOUT = 15
|
|
275
386
|
|
|
276
|
-
#
|
|
387
|
+
# Default value for alarm signal handler timeout; in seconds
|
|
388
|
+
ALARM_TIMEOUT = 15
|
|
389
|
+
ALARM_RETRY = 10
|
|
390
|
+
|
|
391
|
+
# Variables for caching functionality of the Spotify access and refresh token to avoid unnecessary refreshing
|
|
277
392
|
SP_CACHED_ACCESS_TOKEN = None
|
|
278
|
-
|
|
393
|
+
SP_CACHED_REFRESH_TOKEN = None
|
|
394
|
+
SP_ACCESS_TOKEN_EXPIRES_AT = 0
|
|
279
395
|
SP_CACHED_CLIENT_ID = ""
|
|
280
396
|
SP_CACHED_USER_AGENT = ""
|
|
281
397
|
|
|
@@ -285,9 +401,9 @@ TOKEN_URL = "https://open.spotify.com/get_access_token"
|
|
|
285
401
|
# URL of the endpoint to get server time needed to create TOTP object
|
|
286
402
|
SERVER_TIME_URL = "https://open.spotify.com/server-time"
|
|
287
403
|
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
404
|
+
# Variables for caching functionality of the Spotify client token to avoid unnecessary refreshing
|
|
405
|
+
SP_CACHED_CLIENT_TOKEN = None
|
|
406
|
+
SP_CLIENT_TOKEN_EXPIRES_AT = 0
|
|
291
407
|
|
|
292
408
|
LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / SPOTIFY_CHECK_INTERVAL
|
|
293
409
|
|
|
@@ -323,20 +439,18 @@ from email.mime.multipart import MIMEMultipart
|
|
|
323
439
|
from email.mime.text import MIMEText
|
|
324
440
|
import argparse
|
|
325
441
|
import csv
|
|
326
|
-
from urllib.parse import quote_plus, quote
|
|
442
|
+
from urllib.parse import quote_plus, quote, urlparse
|
|
327
443
|
import subprocess
|
|
328
444
|
import platform
|
|
329
445
|
import re
|
|
330
446
|
import ipaddress
|
|
331
447
|
from html import escape
|
|
332
|
-
try:
|
|
333
|
-
import pyotp
|
|
334
|
-
except ModuleNotFoundError:
|
|
335
|
-
raise SystemExit("Error: Couldn't find the pyotp library !\n\nTo install it, run:\n pip3 install pyotp\n\nOnce installed, re-run this tool")
|
|
336
448
|
import base64
|
|
337
449
|
import random
|
|
338
450
|
import shutil
|
|
339
451
|
from pathlib import Path
|
|
452
|
+
import secrets
|
|
453
|
+
from typing import Optional
|
|
340
454
|
|
|
341
455
|
import urllib3
|
|
342
456
|
if not VERIFY_SSL:
|
|
@@ -536,7 +650,7 @@ def send_email(subject, body, body_html, use_ssl, smtp_timeout=15):
|
|
|
536
650
|
return 1
|
|
537
651
|
|
|
538
652
|
if not SMTP_USER or not isinstance(SMTP_USER, str) or SMTP_USER == "your_smtp_user" or not SMTP_PASSWORD or not isinstance(SMTP_PASSWORD, str) or SMTP_PASSWORD == "your_smtp_password":
|
|
539
|
-
print("Error sending email - SMTP settings are incorrect (check SMTP_USER & SMTP_PASSWORD
|
|
653
|
+
print("Error sending email - SMTP settings are incorrect (check SMTP_USER & SMTP_PASSWORD configuration options)")
|
|
540
654
|
return 1
|
|
541
655
|
|
|
542
656
|
if not subject or not isinstance(subject, str):
|
|
@@ -772,10 +886,16 @@ def decrease_inactivity_check_signal_handler(sig, frame):
|
|
|
772
886
|
print_cur_ts("Timestamp:\t\t\t")
|
|
773
887
|
|
|
774
888
|
|
|
775
|
-
# Signal handler for SIGHUP allowing to reload secrets from
|
|
889
|
+
# Signal handler for SIGHUP allowing to reload secrets from dotenv files and token source credentials
|
|
890
|
+
# from login & client token requests body files
|
|
776
891
|
def reload_secrets_signal_handler(sig, frame):
|
|
892
|
+
global DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN, LOGIN_URL, USER_AGENT, APP_VERSION, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR, CLIENT_MODEL
|
|
893
|
+
|
|
777
894
|
sig_name = signal.Signals(sig).name
|
|
778
|
-
|
|
895
|
+
|
|
896
|
+
print(f"* Signal {sig_name} received\n")
|
|
897
|
+
|
|
898
|
+
suffix = "\n" if TOKEN_SOURCE == 'client' else ""
|
|
779
899
|
|
|
780
900
|
# disable autoscan if DOTENV_FILE set to none
|
|
781
901
|
if DOTENV_FILE and DOTENV_FILE.lower() == 'none':
|
|
@@ -791,10 +911,10 @@ def reload_secrets_signal_handler(sig, frame):
|
|
|
791
911
|
if env_path:
|
|
792
912
|
load_dotenv(env_path, override=True)
|
|
793
913
|
else:
|
|
794
|
-
print("* No .env file found, skipping env-var reload")
|
|
914
|
+
print(f"* No .env file found, skipping env-var reload{suffix}")
|
|
795
915
|
except ImportError:
|
|
796
916
|
env_path = None
|
|
797
|
-
print("* python-dotenv not installed, skipping env-var reload")
|
|
917
|
+
print(f"* python-dotenv not installed, skipping env-var reload{suffix}")
|
|
798
918
|
|
|
799
919
|
if env_path:
|
|
800
920
|
for secret in SECRET_KEYS:
|
|
@@ -802,12 +922,49 @@ def reload_secrets_signal_handler(sig, frame):
|
|
|
802
922
|
val = os.getenv(secret)
|
|
803
923
|
if val is not None and val != old_val:
|
|
804
924
|
globals()[secret] = val
|
|
805
|
-
print(f"* Reloaded {secret} from {env_path}")
|
|
925
|
+
print(f"* Reloaded {secret} from {env_path}{suffix}")
|
|
926
|
+
|
|
927
|
+
if TOKEN_SOURCE == 'client':
|
|
928
|
+
|
|
929
|
+
# Process the login request body file
|
|
930
|
+
if LOGIN_REQUEST_BODY_FILE:
|
|
931
|
+
if os.path.isfile(LOGIN_REQUEST_BODY_FILE):
|
|
932
|
+
try:
|
|
933
|
+
DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_request_body_file(LOGIN_REQUEST_BODY_FILE)
|
|
934
|
+
except Exception as e:
|
|
935
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
936
|
+
else:
|
|
937
|
+
print(f"* Login data correctly read from Protobuf file ({LOGIN_REQUEST_BODY_FILE}):")
|
|
938
|
+
print(" - Device ID:\t\t", DEVICE_ID)
|
|
939
|
+
print(" - System ID:\t\t", SYSTEM_ID)
|
|
940
|
+
print(" - User URI ID:\t\t", USER_URI_ID)
|
|
941
|
+
print(" - Refresh Token:\t<<hidden>>\n")
|
|
942
|
+
else:
|
|
943
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) does not exist")
|
|
944
|
+
|
|
945
|
+
# Process the client token request body file
|
|
946
|
+
if CLIENTTOKEN_REQUEST_BODY_FILE:
|
|
947
|
+
if os.path.isfile(CLIENTTOKEN_REQUEST_BODY_FILE):
|
|
948
|
+
try:
|
|
949
|
+
(APP_VERSION, _, _, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR, CLIENT_MODEL) = parse_clienttoken_request_body_file(CLIENTTOKEN_REQUEST_BODY_FILE)
|
|
950
|
+
except Exception as e:
|
|
951
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
952
|
+
else:
|
|
953
|
+
print(f"* Client token data correctly read from Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}):")
|
|
954
|
+
print(" - App version:\t\t", APP_VERSION)
|
|
955
|
+
print(" - CPU arch:\t\t", CPU_ARCH)
|
|
956
|
+
print(" - OS build:\t\t", OS_BUILD)
|
|
957
|
+
print(" - Platform:\t\t", PLATFORM)
|
|
958
|
+
print(" - OS major:\t\t", OS_MAJOR)
|
|
959
|
+
print(" - OS minor:\t\t", OS_MINOR)
|
|
960
|
+
print(" - Client model:\t", CLIENT_MODEL, "\n")
|
|
961
|
+
else:
|
|
962
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) does not exist")
|
|
806
963
|
|
|
807
964
|
print_cur_ts("Timestamp:\t\t\t")
|
|
808
965
|
|
|
809
966
|
|
|
810
|
-
#
|
|
967
|
+
# Returns Apple & Genius search URLs for specified track
|
|
811
968
|
def get_apple_genius_search_urls(artist, track):
|
|
812
969
|
genius_search_string = f"{artist} {track}"
|
|
813
970
|
youtube_music_search_string = quote_plus(f"{artist} {track}")
|
|
@@ -820,6 +977,35 @@ def get_apple_genius_search_urls(artist, track):
|
|
|
820
977
|
return apple_search_url, genius_search_url, youtube_music_search_url
|
|
821
978
|
|
|
822
979
|
|
|
980
|
+
# Sends a lightweight request to check Spotify token validity
|
|
981
|
+
def check_token_validity(access_token: str, client_id: Optional[str] = None, user_agent: Optional[str] = None) -> bool:
|
|
982
|
+
url = "https://api.spotify.com/v1/me"
|
|
983
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
984
|
+
|
|
985
|
+
if TOKEN_SOURCE == "cookie" and client_id is not None and user_agent is not None:
|
|
986
|
+
headers.update({
|
|
987
|
+
"Client-Id": client_id,
|
|
988
|
+
"User-Agent": user_agent,
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
if platform.system() != 'Windows':
|
|
992
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
993
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
994
|
+
try:
|
|
995
|
+
response = req.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
996
|
+
valid = response.status_code == 200
|
|
997
|
+
except Exception:
|
|
998
|
+
valid = False
|
|
999
|
+
finally:
|
|
1000
|
+
if platform.system() != 'Windows':
|
|
1001
|
+
signal.alarm(0)
|
|
1002
|
+
return valid
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
# -------------------------------------------------------
|
|
1006
|
+
# Supporting functions when token source is set to cookie
|
|
1007
|
+
# -------------------------------------------------------
|
|
1008
|
+
|
|
823
1009
|
# Returns random user agent string
|
|
824
1010
|
def get_random_user_agent() -> str:
|
|
825
1011
|
browser = random.choice(['chrome', 'firefox', 'edge', 'safari'])
|
|
@@ -909,6 +1095,8 @@ def hex_to_bytes(data: str) -> bytes:
|
|
|
909
1095
|
|
|
910
1096
|
# Creates a TOTP object using a secret derived from transformed cipher bytes
|
|
911
1097
|
def generate_totp(ua: str):
|
|
1098
|
+
import pyotp
|
|
1099
|
+
|
|
912
1100
|
secret_cipher_bytes = [
|
|
913
1101
|
12, 56, 76, 33, 88, 44, 88, 33,
|
|
914
1102
|
78, 78, 11, 66, 22, 22, 55, 69, 54,
|
|
@@ -951,29 +1139,6 @@ def generate_totp(ua: str):
|
|
|
951
1139
|
return totp_obj, server_time
|
|
952
1140
|
|
|
953
1141
|
|
|
954
|
-
# Sends a lightweight request to check Spotify token validity
|
|
955
|
-
def check_token_validity(token: str, client_id: str, user_agent: str) -> bool:
|
|
956
|
-
url = "https://api.spotify.com/v1/me"
|
|
957
|
-
headers = {
|
|
958
|
-
"Authorization": f"Bearer {token}",
|
|
959
|
-
"Client-Id": client_id,
|
|
960
|
-
"User-Agent": user_agent,
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
if platform.system() != 'Windows':
|
|
964
|
-
signal.signal(signal.SIGALRM, timeout_handler)
|
|
965
|
-
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
966
|
-
try:
|
|
967
|
-
response = req.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
968
|
-
valid = response.status_code == 200
|
|
969
|
-
except Exception:
|
|
970
|
-
valid = False
|
|
971
|
-
finally:
|
|
972
|
-
if platform.system() != 'Windows':
|
|
973
|
-
signal.alarm(0)
|
|
974
|
-
return valid
|
|
975
|
-
|
|
976
|
-
|
|
977
1142
|
# Retrieves a new Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
|
|
978
1143
|
def refresh_token(sp_dc: str) -> dict:
|
|
979
1144
|
transport = True
|
|
@@ -1052,12 +1217,12 @@ def refresh_token(sp_dc: str) -> dict:
|
|
|
1052
1217
|
|
|
1053
1218
|
|
|
1054
1219
|
# Fetches Spotify access token based on provided SP_DC value
|
|
1055
|
-
def
|
|
1056
|
-
global SP_CACHED_ACCESS_TOKEN,
|
|
1220
|
+
def spotify_get_access_token_from_sp_dc(sp_dc: str):
|
|
1221
|
+
global SP_CACHED_ACCESS_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT
|
|
1057
1222
|
|
|
1058
1223
|
now = time.time()
|
|
1059
1224
|
|
|
1060
|
-
if SP_CACHED_ACCESS_TOKEN and now <
|
|
1225
|
+
if SP_CACHED_ACCESS_TOKEN and now < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT):
|
|
1061
1226
|
return SP_CACHED_ACCESS_TOKEN
|
|
1062
1227
|
|
|
1063
1228
|
max_retries = TOKEN_MAX_RETRIES
|
|
@@ -1071,7 +1236,7 @@ def spotify_get_access_token(sp_dc: str):
|
|
|
1071
1236
|
length = token_data["length"]
|
|
1072
1237
|
|
|
1073
1238
|
SP_CACHED_ACCESS_TOKEN = token
|
|
1074
|
-
|
|
1239
|
+
SP_ACCESS_TOKEN_EXPIRES_AT = token_data["expires_at"]
|
|
1075
1240
|
SP_CACHED_CLIENT_ID = client_id
|
|
1076
1241
|
SP_CACHED_USER_AGENT = user_agent
|
|
1077
1242
|
|
|
@@ -1096,14 +1261,493 @@ def spotify_get_access_token(sp_dc: str):
|
|
|
1096
1261
|
return SP_CACHED_ACCESS_TOKEN
|
|
1097
1262
|
|
|
1098
1263
|
|
|
1264
|
+
# -------------------------------------------------------
|
|
1265
|
+
# Supporting functions when token source is set to client
|
|
1266
|
+
# -------------------------------------------------------
|
|
1267
|
+
|
|
1268
|
+
# Returns random Spotify client user agent string
|
|
1269
|
+
def get_random_spotify_user_agent() -> str:
|
|
1270
|
+
os_choice = random.choice(['windows', 'mac', 'linux'])
|
|
1271
|
+
|
|
1272
|
+
if os_choice == 'windows':
|
|
1273
|
+
build = random.randint(120000000, 130000000)
|
|
1274
|
+
arch = random.choice(['Win32', 'Win32_x86_64'])
|
|
1275
|
+
device = random.choice(['desktop', 'laptop'])
|
|
1276
|
+
return f"Spotify/{build} {arch}/0 (PC {device})"
|
|
1277
|
+
|
|
1278
|
+
elif os_choice == 'mac':
|
|
1279
|
+
build = random.randint(120000000, 130000000)
|
|
1280
|
+
arch = random.choice(['OSX_ARM64', 'OSX_X86_64'])
|
|
1281
|
+
major = random.randint(10, 15)
|
|
1282
|
+
minor = random.randint(0, 7)
|
|
1283
|
+
patch = random.randint(0, 5)
|
|
1284
|
+
os_version = f"OS X {major}.{minor}.{patch}"
|
|
1285
|
+
if arch == 'OSX_ARM64':
|
|
1286
|
+
bracket = f"[arm {random.randint(1, 3)}]"
|
|
1287
|
+
else:
|
|
1288
|
+
bracket = "[x86_64]"
|
|
1289
|
+
return f"Spotify/{build} {arch}/{os_version} {bracket}"
|
|
1290
|
+
|
|
1291
|
+
else: # linux
|
|
1292
|
+
build = random.randint(120000000, 130000000)
|
|
1293
|
+
arch = random.choice(['Linux; x86_64', 'Linux; x86'])
|
|
1294
|
+
return f"Spotify/{build} ({arch})"
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
# Encodes an integer using Protobuf varint format
|
|
1298
|
+
def encode_varint(value):
|
|
1299
|
+
result = bytearray()
|
|
1300
|
+
while value > 0x7F:
|
|
1301
|
+
result.append((value & 0x7F) | 0x80)
|
|
1302
|
+
value //= 128
|
|
1303
|
+
result.append(value)
|
|
1304
|
+
return bytes(result)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
# Encodes a string field with the given tag
|
|
1308
|
+
def encode_string_field(tag, value):
|
|
1309
|
+
key = encode_varint((tag << 3) | 2) # wire type 2 (length-delimited)
|
|
1310
|
+
value_bytes = value.encode('utf-8')
|
|
1311
|
+
length = encode_varint(len(value_bytes))
|
|
1312
|
+
return key + length + value_bytes
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
# Encodes a nested message field with the given tag
|
|
1316
|
+
def encode_nested_field(tag, nested_bytes):
|
|
1317
|
+
key = encode_varint((tag << 3) | 2)
|
|
1318
|
+
length = encode_varint(len(nested_bytes))
|
|
1319
|
+
return key + length + nested_bytes
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
# Builds the Spotify Protobuf login request body
|
|
1323
|
+
def build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token):
|
|
1324
|
+
"""
|
|
1325
|
+
1 {
|
|
1326
|
+
1: "device_id"
|
|
1327
|
+
2: "system_id"
|
|
1328
|
+
}
|
|
1329
|
+
100 {
|
|
1330
|
+
1: "user_uri_id"
|
|
1331
|
+
2: "refresh_token"
|
|
1332
|
+
}
|
|
1333
|
+
"""
|
|
1334
|
+
device_info_msg = encode_string_field(1, device_id) + encode_string_field(2, system_id)
|
|
1335
|
+
field_device_info = encode_nested_field(1, device_info_msg)
|
|
1336
|
+
|
|
1337
|
+
user_auth_msg = encode_string_field(1, user_uri_id) + encode_string_field(2, refresh_token)
|
|
1338
|
+
field_user_auth = encode_nested_field(100, user_auth_msg)
|
|
1339
|
+
|
|
1340
|
+
return field_device_info + field_user_auth
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
# Reads a varint from data starting at index
|
|
1344
|
+
def read_varint(data, index):
|
|
1345
|
+
shift = 0
|
|
1346
|
+
result = 0
|
|
1347
|
+
bytes_read = 0
|
|
1348
|
+
while True:
|
|
1349
|
+
b = data[index]
|
|
1350
|
+
result |= ((b & 0x7F) << shift)
|
|
1351
|
+
bytes_read += 1
|
|
1352
|
+
index += 1
|
|
1353
|
+
if not (b & 0x80):
|
|
1354
|
+
break
|
|
1355
|
+
shift += 7
|
|
1356
|
+
return result, bytes_read
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# Parses Spotify Protobuf login response
|
|
1360
|
+
def parse_protobuf_message(data):
|
|
1361
|
+
"""
|
|
1362
|
+
Recursively parses a Protobuf message, returns a dictionary mapping tags to values
|
|
1363
|
+
|
|
1364
|
+
If a length-delimited field's first byte is a control character (i.e. < 0x20), we assume it is a nested message
|
|
1365
|
+
and parse it recursively, otherwise we decode it as UTF-8
|
|
1366
|
+
"""
|
|
1367
|
+
index = 0
|
|
1368
|
+
result = {}
|
|
1369
|
+
while index < len(data):
|
|
1370
|
+
try:
|
|
1371
|
+
key, key_len = read_varint(data, index)
|
|
1372
|
+
except IndexError:
|
|
1373
|
+
break
|
|
1374
|
+
index += key_len
|
|
1375
|
+
tag = key >> 3
|
|
1376
|
+
wire_type = key & 0x07
|
|
1377
|
+
if wire_type == 2: # length-delimited
|
|
1378
|
+
length, len_len = read_varint(data, index)
|
|
1379
|
+
index += len_len
|
|
1380
|
+
raw_value = data[index:index + length]
|
|
1381
|
+
index += length
|
|
1382
|
+
# If the first byte is a control character (e.g. 0x0A) assume nested
|
|
1383
|
+
if raw_value and raw_value[0] < 0x20:
|
|
1384
|
+
value = parse_protobuf_message(raw_value)
|
|
1385
|
+
else:
|
|
1386
|
+
try:
|
|
1387
|
+
value = raw_value.decode('utf-8')
|
|
1388
|
+
except UnicodeDecodeError:
|
|
1389
|
+
value = raw_value
|
|
1390
|
+
result[tag] = value
|
|
1391
|
+
elif wire_type == 0: # varint
|
|
1392
|
+
value, var_len = read_varint(data, index)
|
|
1393
|
+
index += var_len
|
|
1394
|
+
result[tag] = value
|
|
1395
|
+
else:
|
|
1396
|
+
break
|
|
1397
|
+
return result # dictionary mapping tags to values
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
# Parses the Protobuf-encoded login request body file (as dumped for example by Proxyman) and returns a tuple:
|
|
1401
|
+
# (device_id, system_id, user_uri_id, refresh_token)
|
|
1402
|
+
def parse_request_body_file(file_path):
|
|
1403
|
+
"""
|
|
1404
|
+
Expected structure:
|
|
1405
|
+
{
|
|
1406
|
+
1: {
|
|
1407
|
+
1: "device_id",
|
|
1408
|
+
2: "system_id"
|
|
1409
|
+
},
|
|
1410
|
+
100: {
|
|
1411
|
+
1: "user_uri_id",
|
|
1412
|
+
2: "refresh_token"
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
"""
|
|
1416
|
+
with open(file_path, "rb") as f:
|
|
1417
|
+
data = f.read()
|
|
1418
|
+
parsed = parse_protobuf_message(data)
|
|
1419
|
+
|
|
1420
|
+
device_id = None
|
|
1421
|
+
system_id = None
|
|
1422
|
+
user_uri_id = None
|
|
1423
|
+
refresh_token = None
|
|
1424
|
+
|
|
1425
|
+
if 1 in parsed:
|
|
1426
|
+
device_info = parsed[1]
|
|
1427
|
+
if isinstance(device_info, dict):
|
|
1428
|
+
device_id = device_info.get(1)
|
|
1429
|
+
system_id = device_info.get(2)
|
|
1430
|
+
else:
|
|
1431
|
+
pass
|
|
1432
|
+
|
|
1433
|
+
if 100 in parsed:
|
|
1434
|
+
user_auth = parsed[100]
|
|
1435
|
+
if isinstance(user_auth, dict):
|
|
1436
|
+
user_uri_id = user_auth.get(1)
|
|
1437
|
+
refresh_token = user_auth.get(2)
|
|
1438
|
+
|
|
1439
|
+
protobuf_fields = {
|
|
1440
|
+
"device_id": device_id,
|
|
1441
|
+
"system_id": system_id,
|
|
1442
|
+
"user_uri_id": user_uri_id,
|
|
1443
|
+
"refresh_token": refresh_token,
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
protobuf_missing_fields = [name for name, value in protobuf_fields.items() if value is None]
|
|
1447
|
+
|
|
1448
|
+
if protobuf_missing_fields:
|
|
1449
|
+
missing_str = ", ".join(protobuf_missing_fields)
|
|
1450
|
+
raise Exception(f"Following fields could not be extracted: {missing_str}")
|
|
1451
|
+
|
|
1452
|
+
return device_id, system_id, user_uri_id, refresh_token
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
# Recursively flattens nested dictionaries or lists into a single string
|
|
1456
|
+
def deep_flatten(value):
|
|
1457
|
+
if isinstance(value, dict):
|
|
1458
|
+
return "".join(deep_flatten(v) for k, v in sorted(value.items()))
|
|
1459
|
+
elif isinstance(value, list):
|
|
1460
|
+
return "".join(deep_flatten(item) for item in value)
|
|
1461
|
+
else:
|
|
1462
|
+
return str(value)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
# Returns the input if it's a dict, parses as Protobuf it if it's bytes or returns an empty dict otherwise
|
|
1466
|
+
def ensure_dict(value):
|
|
1467
|
+
if isinstance(value, dict):
|
|
1468
|
+
return value
|
|
1469
|
+
if isinstance(value, (bytes, bytearray)):
|
|
1470
|
+
try:
|
|
1471
|
+
return parse_protobuf_message(value)
|
|
1472
|
+
except Exception:
|
|
1473
|
+
return {}
|
|
1474
|
+
return {}
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
# Parses the Protobuf-encoded client token request body file (as dumped for example by Proxyman) and returns a tuple:
|
|
1478
|
+
# (app_version, device_id, system_id, cpu_arch, os_build, platform, os_major, os_minor, client_model)
|
|
1479
|
+
def parse_clienttoken_request_body_file(file_path):
|
|
1480
|
+
|
|
1481
|
+
with open(file_path, "rb") as f:
|
|
1482
|
+
data = f.read()
|
|
1483
|
+
|
|
1484
|
+
root = ensure_dict(parse_protobuf_message(data).get(2))
|
|
1485
|
+
|
|
1486
|
+
app_version = root.get(1)
|
|
1487
|
+
device_id = root.get(2)
|
|
1488
|
+
|
|
1489
|
+
nested_3 = ensure_dict(root.get(3))
|
|
1490
|
+
nested_1 = ensure_dict(nested_3.get(1))
|
|
1491
|
+
nested_4 = ensure_dict(nested_1.get(4))
|
|
1492
|
+
|
|
1493
|
+
cpu_arch = nested_4.get(1)
|
|
1494
|
+
os_build = nested_4.get(3)
|
|
1495
|
+
platform = nested_4.get(4)
|
|
1496
|
+
os_major = nested_4.get(5)
|
|
1497
|
+
os_minor = nested_4.get(6)
|
|
1498
|
+
client_model = nested_4.get(8)
|
|
1499
|
+
|
|
1500
|
+
system_id = nested_3.get(2)
|
|
1501
|
+
|
|
1502
|
+
required = {
|
|
1503
|
+
"app_version": app_version,
|
|
1504
|
+
"device_id": device_id,
|
|
1505
|
+
"system_id": system_id,
|
|
1506
|
+
}
|
|
1507
|
+
missing = [k for k, v in required.items() if v is None]
|
|
1508
|
+
if missing:
|
|
1509
|
+
raise Exception(f"Could not extract fields: {', '.join(missing)}")
|
|
1510
|
+
|
|
1511
|
+
return (app_version, device_id, system_id, cpu_arch, os_build, platform, os_major, os_minor, client_model)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
# Converts Spotify user agent string to Protobuf app_version string
|
|
1515
|
+
# For example: 'Spotify/126200580 Win32_x86_64/0 (PC desktop)' to '1.2.62.580.g<random-hex>'
|
|
1516
|
+
def ua_to_app_version(user_agent: str) -> str:
|
|
1517
|
+
|
|
1518
|
+
m = re.search(r"Spotify/(\d{5,})", user_agent)
|
|
1519
|
+
if not m:
|
|
1520
|
+
raise ValueError(f"User-Agent missing build number: {user_agent!r}")
|
|
1521
|
+
|
|
1522
|
+
digits = m.group(1)
|
|
1523
|
+
if len(digits) < 5:
|
|
1524
|
+
raise ValueError(f"Build number too short: {digits}")
|
|
1525
|
+
|
|
1526
|
+
major = digits[0]
|
|
1527
|
+
minor = digits[1]
|
|
1528
|
+
patch = str(int(digits[2:4]))
|
|
1529
|
+
build = str(int(digits[4:]))
|
|
1530
|
+
suffix = secrets.token_hex(4)
|
|
1531
|
+
|
|
1532
|
+
return f"{major}.{minor}.{patch}.{build}.g{suffix}"
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
# Builds the Protobuf client token request body
|
|
1536
|
+
def build_clienttoken_request_protobuf(app_version, device_id, system_id, cpu_arch=10, os_build=19045, platform=2, os_major=9, os_minor=9, client_model=34404):
|
|
1537
|
+
"""
|
|
1538
|
+
1: 1 (const)
|
|
1539
|
+
2: {
|
|
1540
|
+
1: app_version
|
|
1541
|
+
2: device_id
|
|
1542
|
+
3: {
|
|
1543
|
+
1: {
|
|
1544
|
+
4: device_details
|
|
1545
|
+
}
|
|
1546
|
+
2: system_id
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
"""
|
|
1550
|
+
|
|
1551
|
+
leaf = (
|
|
1552
|
+
encode_varint((1 << 3) | 0) + encode_varint(cpu_arch) + encode_varint((3 << 3) | 0) + encode_varint(os_build) + encode_varint((4 << 3) | 0) + encode_varint(platform) + encode_varint((5 << 3) | 0) + encode_varint(os_major) + encode_varint((6 << 3) | 0) + encode_varint(os_minor) + encode_varint((8 << 3) | 0) + encode_varint(client_model))
|
|
1553
|
+
|
|
1554
|
+
msg_4 = encode_nested_field(4, leaf)
|
|
1555
|
+
msg_1 = encode_nested_field(1, msg_4)
|
|
1556
|
+
msg_3 = msg_1 + encode_string_field(2, system_id)
|
|
1557
|
+
|
|
1558
|
+
payload = (encode_string_field(1, app_version) + encode_string_field(2, device_id) + encode_nested_field(3, msg_3))
|
|
1559
|
+
|
|
1560
|
+
root = (encode_varint((1 << 3) | 0) + encode_varint(1) + encode_nested_field(2, payload))
|
|
1561
|
+
|
|
1562
|
+
return root
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
# Fetches Spotify access token based on provided device_id, system_id, user_uri_id, refresh_token and client_token value
|
|
1566
|
+
def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token):
|
|
1567
|
+
global SP_CACHED_ACCESS_TOKEN, SP_CACHED_REFRESH_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT
|
|
1568
|
+
|
|
1569
|
+
if SP_CACHED_ACCESS_TOKEN and time.time() < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN):
|
|
1570
|
+
return SP_CACHED_ACCESS_TOKEN
|
|
1571
|
+
|
|
1572
|
+
if not client_token:
|
|
1573
|
+
raise Exception("Client token is missing")
|
|
1574
|
+
|
|
1575
|
+
if SP_CACHED_REFRESH_TOKEN:
|
|
1576
|
+
refresh_token = SP_CACHED_REFRESH_TOKEN
|
|
1577
|
+
|
|
1578
|
+
protobuf_body = build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token)
|
|
1579
|
+
|
|
1580
|
+
parsed_url = urlparse(LOGIN_URL)
|
|
1581
|
+
host = parsed_url.netloc # e.g., "login5.spotify.com"
|
|
1582
|
+
origin = f"{parsed_url.scheme}://{parsed_url.netloc}" # e.g., "https://login5.spotify.com"
|
|
1583
|
+
|
|
1584
|
+
headers = {
|
|
1585
|
+
"Host": host,
|
|
1586
|
+
"Connection": "keep-alive",
|
|
1587
|
+
"Content-Type": "application/x-protobuf",
|
|
1588
|
+
"User-Agent": USER_AGENT,
|
|
1589
|
+
"X-Retry-Count": "0",
|
|
1590
|
+
"Client-Token": client_token,
|
|
1591
|
+
"Origin": origin,
|
|
1592
|
+
"Accept-Language": "en-Latn-GB,en-GB;q=0.9,en;q=0.8",
|
|
1593
|
+
"Sec-Fetch-Site": "same-origin",
|
|
1594
|
+
"Sec-Fetch-Mode": "no-cors",
|
|
1595
|
+
"Sec-Fetch-Dest": "empty",
|
|
1596
|
+
"Accept-Encoding": "gzip, deflate, br, zstd"
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
try:
|
|
1600
|
+
if platform.system() != 'Windows':
|
|
1601
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1602
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1603
|
+
response = req.post(LOGIN_URL, headers=headers, data=protobuf_body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1604
|
+
except (req.RequestException, TimeoutException) as e:
|
|
1605
|
+
raise Exception(f"spotify_get_access_token_from_client() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
|
|
1606
|
+
finally:
|
|
1607
|
+
if platform.system() != 'Windows':
|
|
1608
|
+
signal.alarm(0)
|
|
1609
|
+
|
|
1610
|
+
if response.status_code != 200:
|
|
1611
|
+
if response.headers.get("client-token-error") == "INVALID_CLIENTTOKEN":
|
|
1612
|
+
raise Exception(f"Request failed with status {response.status_code}: invalid client token")
|
|
1613
|
+
elif response.headers.get("client-token-error") == "EXPIRED_CLIENTTOKEN":
|
|
1614
|
+
raise Exception(f"Request failed with status {response.status_code}: expired client token")
|
|
1615
|
+
|
|
1616
|
+
raise Exception(f"Request failed with status code {response.status_code}\nResponse Headers: {response.headers}\nResponse Content (raw): {response.content}\nResponse text: {response.text}")
|
|
1617
|
+
|
|
1618
|
+
parsed = parse_protobuf_message(response.content)
|
|
1619
|
+
# {1: {1: user_uri_id, 2: access_token, 3: refresh_token, 4: expires_in}}
|
|
1620
|
+
access_token_raw = None
|
|
1621
|
+
expires_in = 3600 # default
|
|
1622
|
+
if 1 in parsed and isinstance(parsed[1], dict):
|
|
1623
|
+
nested = parsed[1]
|
|
1624
|
+
access_token_raw = nested.get(2)
|
|
1625
|
+
user_uri_id = parsed[1].get(1)
|
|
1626
|
+
|
|
1627
|
+
if 4 in nested:
|
|
1628
|
+
raw_expires = nested.get(4)
|
|
1629
|
+
if isinstance(raw_expires, (int, str, bytes)):
|
|
1630
|
+
try:
|
|
1631
|
+
expires_in = int(raw_expires)
|
|
1632
|
+
except ValueError:
|
|
1633
|
+
expires_in = 3600
|
|
1634
|
+
|
|
1635
|
+
access_token = deep_flatten(access_token_raw) if access_token_raw else None
|
|
1636
|
+
|
|
1637
|
+
if not access_token:
|
|
1638
|
+
raise Exception("Access token not found in response")
|
|
1639
|
+
|
|
1640
|
+
SP_CACHED_ACCESS_TOKEN = access_token
|
|
1641
|
+
SP_CACHED_REFRESH_TOKEN = parsed[1].get(3)
|
|
1642
|
+
SP_ACCESS_TOKEN_EXPIRES_AT = time.time() + expires_in
|
|
1643
|
+
return access_token
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
# Fetches fresh client token
|
|
1647
|
+
def spotify_get_client_token(app_version, device_id, system_id, **device_overrides):
|
|
1648
|
+
global SP_CACHED_CLIENT_TOKEN, SP_CLIENT_TOKEN_EXPIRES_AT
|
|
1649
|
+
|
|
1650
|
+
if SP_CACHED_CLIENT_TOKEN and time.time() < SP_CLIENT_TOKEN_EXPIRES_AT:
|
|
1651
|
+
return SP_CACHED_CLIENT_TOKEN
|
|
1652
|
+
|
|
1653
|
+
body = build_clienttoken_request_protobuf(app_version, device_id, system_id, **device_overrides)
|
|
1654
|
+
|
|
1655
|
+
headers = {
|
|
1656
|
+
"Host": "clienttoken.spotify.com",
|
|
1657
|
+
"Connection": "keep-alive",
|
|
1658
|
+
"Pragma": "no-cache",
|
|
1659
|
+
"Cache-Control": "no-cache, no-store, max-age=0",
|
|
1660
|
+
"Accept": "application/x-protobuf",
|
|
1661
|
+
"Content-Type": "application/x-protobuf",
|
|
1662
|
+
"User-Agent": USER_AGENT,
|
|
1663
|
+
"Origin": "https://clienttoken.spotify.com",
|
|
1664
|
+
"Accept-Language": "en-Latn-GB,en-GB;q=0.9,en;q=0.8",
|
|
1665
|
+
"Sec-Fetch-Site": "same-origin",
|
|
1666
|
+
"Sec-Fetch-Mode": "no-cors",
|
|
1667
|
+
"Sec-Fetch-Dest": "empty",
|
|
1668
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
try:
|
|
1672
|
+
if platform.system() != 'Windows':
|
|
1673
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1674
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1675
|
+
response = req.post(CLIENTTOKEN_URL, headers=headers, data=body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1676
|
+
except (req.RequestException, TimeoutException) as e:
|
|
1677
|
+
raise Exception(f"spotify_get_client_token() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
|
|
1678
|
+
finally:
|
|
1679
|
+
if platform.system() != 'Windows':
|
|
1680
|
+
signal.alarm(0)
|
|
1681
|
+
|
|
1682
|
+
if response.status_code != 200:
|
|
1683
|
+
raise Exception(f"clienttoken request failed - status {response.status_code}\nHeaders: {response.headers}\nBody (raw): {response.content[:120]}...")
|
|
1684
|
+
|
|
1685
|
+
parsed = parse_protobuf_message(response.content)
|
|
1686
|
+
inner = parsed.get(2, {})
|
|
1687
|
+
client_token = deep_flatten(inner.get(1)) if inner.get(1) else None
|
|
1688
|
+
ttl = int(inner.get(3, 0)) or 1209600 # ≈ 2 weeks fallback
|
|
1689
|
+
|
|
1690
|
+
if not client_token:
|
|
1691
|
+
raise Exception("clienttoken response did not contain a token")
|
|
1692
|
+
|
|
1693
|
+
SP_CACHED_CLIENT_TOKEN = client_token
|
|
1694
|
+
SP_CLIENT_TOKEN_EXPIRES_AT = time.time() + ttl
|
|
1695
|
+
|
|
1696
|
+
return client_token
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
# Fetches Spotify access token with automatic client token refresh
|
|
1700
|
+
def spotify_get_access_token_from_client_auto(device_id, system_id, user_uri_id, refresh_token):
|
|
1701
|
+
client_token = None
|
|
1702
|
+
|
|
1703
|
+
if all([
|
|
1704
|
+
CLIENTTOKEN_URL,
|
|
1705
|
+
APP_VERSION,
|
|
1706
|
+
CPU_ARCH is not None and CPU_ARCH > 0,
|
|
1707
|
+
OS_BUILD is not None and OS_BUILD > 0,
|
|
1708
|
+
PLATFORM is not None and PLATFORM > 0,
|
|
1709
|
+
OS_MAJOR is not None and OS_MAJOR > 0,
|
|
1710
|
+
OS_MINOR is not None and OS_MINOR > 0,
|
|
1711
|
+
CLIENT_MODEL is not None and CLIENT_MODEL > 0
|
|
1712
|
+
]):
|
|
1713
|
+
client_token = spotify_get_client_token(app_version=APP_VERSION, device_id=device_id, system_id=system_id, cpu_arch=CPU_ARCH, os_build=OS_BUILD, platform=PLATFORM, os_major=OS_MAJOR, os_minor=OS_MINOR, client_model=CLIENT_MODEL)
|
|
1714
|
+
|
|
1715
|
+
try:
|
|
1716
|
+
return spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token)
|
|
1717
|
+
except Exception as e:
|
|
1718
|
+
err = str(e).lower()
|
|
1719
|
+
if all([
|
|
1720
|
+
CLIENTTOKEN_URL,
|
|
1721
|
+
APP_VERSION,
|
|
1722
|
+
CPU_ARCH is not None and CPU_ARCH > 0,
|
|
1723
|
+
OS_BUILD is not None and OS_BUILD > 0,
|
|
1724
|
+
PLATFORM is not None and PLATFORM > 0,
|
|
1725
|
+
OS_MAJOR is not None and OS_MAJOR > 0,
|
|
1726
|
+
OS_MINOR is not None and OS_MINOR > 0,
|
|
1727
|
+
CLIENT_MODEL is not None and CLIENT_MODEL > 0
|
|
1728
|
+
]) and ("invalid client token" in err or "expired client token" in err):
|
|
1729
|
+
global SP_CLIENT_TOKEN_EXPIRES_AT
|
|
1730
|
+
SP_CLIENT_TOKEN_EXPIRES_AT = 0
|
|
1731
|
+
|
|
1732
|
+
client_token = spotify_get_client_token(app_version=APP_VERSION, device_id=DEVICE_ID, system_id=SYSTEM_ID, cpu_arch=CPU_ARCH, os_build=OS_BUILD, platform=PLATFORM, os_major=OS_MAJOR, os_minor=OS_MINOR, client_model=CLIENT_MODEL)
|
|
1733
|
+
|
|
1734
|
+
return spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token)
|
|
1735
|
+
raise
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
# --------------------------------------------------------
|
|
1739
|
+
|
|
1740
|
+
|
|
1099
1741
|
# Fetches list of Spotify friends
|
|
1100
1742
|
def spotify_get_friends_json(access_token):
|
|
1101
1743
|
url = "https://guc-spclient.spotify.com/presence-view/v1/buddylist"
|
|
1102
|
-
headers = {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1744
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1745
|
+
|
|
1746
|
+
if TOKEN_SOURCE == "cookie":
|
|
1747
|
+
headers.update({
|
|
1748
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1749
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1750
|
+
})
|
|
1107
1751
|
|
|
1108
1752
|
response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1109
1753
|
if response.status_code == 401:
|
|
@@ -1226,11 +1870,13 @@ def spotify_get_friend_info(friend_activity, uri):
|
|
|
1226
1870
|
def spotify_get_track_info(access_token, track_uri):
|
|
1227
1871
|
track_id = track_uri.split(':', 2)[2]
|
|
1228
1872
|
url = "https://api.spotify.com/v1/tracks/" + track_id
|
|
1229
|
-
headers = {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1873
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1874
|
+
|
|
1875
|
+
if TOKEN_SOURCE == "cookie":
|
|
1876
|
+
headers.update({
|
|
1877
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1878
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1879
|
+
})
|
|
1234
1880
|
# add si parameter so link opens in native Spotify app after clicking
|
|
1235
1881
|
si = "?si=1"
|
|
1236
1882
|
|
|
@@ -1254,11 +1900,13 @@ def spotify_get_track_info(access_token, track_uri):
|
|
|
1254
1900
|
def spotify_get_playlist_info(access_token, playlist_uri):
|
|
1255
1901
|
playlist_id = playlist_uri.split(':', 2)[2]
|
|
1256
1902
|
url = f"https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner,followers,external_urls"
|
|
1257
|
-
headers = {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1903
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1904
|
+
|
|
1905
|
+
if TOKEN_SOURCE == "cookie":
|
|
1906
|
+
headers.update({
|
|
1907
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1908
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1909
|
+
})
|
|
1262
1910
|
# add si parameter so link opens in native Spotify app after clicking
|
|
1263
1911
|
si = "?si=1"
|
|
1264
1912
|
|
|
@@ -1279,15 +1927,17 @@ def spotify_get_playlist_info(access_token, playlist_uri):
|
|
|
1279
1927
|
# Gets basic information about access token owner
|
|
1280
1928
|
def spotify_get_current_user(access_token) -> dict | None:
|
|
1281
1929
|
url = "https://api.spotify.com/v1/me"
|
|
1282
|
-
headers = {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1930
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1931
|
+
|
|
1932
|
+
if TOKEN_SOURCE == "cookie":
|
|
1933
|
+
headers.update({
|
|
1934
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1935
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1936
|
+
})
|
|
1287
1937
|
|
|
1288
1938
|
if platform.system() != 'Windows':
|
|
1289
1939
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1290
|
-
signal.alarm(
|
|
1940
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1291
1941
|
try:
|
|
1292
1942
|
response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1293
1943
|
response.raise_for_status()
|
|
@@ -1447,7 +2097,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1447
2097
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1448
2098
|
signal.alarm(ALARM_TIMEOUT)
|
|
1449
2099
|
try:
|
|
1450
|
-
|
|
2100
|
+
if TOKEN_SOURCE == "client":
|
|
2101
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
2102
|
+
else:
|
|
2103
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
1451
2104
|
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
1452
2105
|
sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
|
|
1453
2106
|
email_sent = False
|
|
@@ -1464,12 +2117,27 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1464
2117
|
if platform.system() != 'Windows':
|
|
1465
2118
|
signal.alarm(0)
|
|
1466
2119
|
|
|
2120
|
+
err = str(e).lower()
|
|
2121
|
+
|
|
1467
2122
|
print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
|
|
1468
2123
|
|
|
1469
|
-
if
|
|
2124
|
+
if TOKEN_SOURCE == 'cookie' and '401' in err:
|
|
1470
2125
|
SP_CACHED_ACCESS_TOKEN = None
|
|
1471
2126
|
|
|
1472
|
-
|
|
2127
|
+
client_errs = ['access token', 'invalid client token', 'expired client token']
|
|
2128
|
+
cookie_errs = ['access token', 'unsuccessful token request']
|
|
2129
|
+
|
|
2130
|
+
if TOKEN_SOURCE == 'client' and any(k in err for k in client_errs):
|
|
2131
|
+
print(f"* Error: client token or refresh token might have expired!")
|
|
2132
|
+
if ERROR_NOTIFICATION and not email_sent:
|
|
2133
|
+
m_subject = f"spotify_monitor: client token or refresh token might have expired! (uri: {user_uri_id})"
|
|
2134
|
+
m_body = f"Client token or refresh token might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
|
|
2135
|
+
m_body_html = f"<html><head></head><body>Client token or refresh token might have expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
|
|
2136
|
+
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
2137
|
+
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
2138
|
+
email_sent = True
|
|
2139
|
+
|
|
2140
|
+
elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
|
|
1473
2141
|
print(f"* Error: sp_dc might have expired!")
|
|
1474
2142
|
if ERROR_NOTIFICATION and not email_sent:
|
|
1475
2143
|
m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
|
|
@@ -1478,6 +2146,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1478
2146
|
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
1479
2147
|
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
1480
2148
|
email_sent = True
|
|
2149
|
+
|
|
1481
2150
|
print_cur_ts("Timestamp:\t\t\t")
|
|
1482
2151
|
time.sleep(SPOTIFY_ERROR_INTERVAL)
|
|
1483
2152
|
continue
|
|
@@ -1494,7 +2163,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1494
2163
|
|
|
1495
2164
|
user_info = spotify_get_current_user(sp_accessToken)
|
|
1496
2165
|
if user_info:
|
|
1497
|
-
print(f"Token belongs to:\t\t{user_info.get('display_name', '')}\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
|
|
2166
|
+
print(f"Token belongs to:\t\t{user_info.get('display_name', '')} (via {TOKEN_SOURCE})\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
|
|
1498
2167
|
|
|
1499
2168
|
sp_track_uri = sp_data["sp_track_uri"]
|
|
1500
2169
|
sp_track_uri_id = sp_data["sp_track_uri_id"]
|
|
@@ -1646,7 +2315,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1646
2315
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1647
2316
|
signal.alarm(ALARM_TIMEOUT)
|
|
1648
2317
|
try:
|
|
1649
|
-
|
|
2318
|
+
if TOKEN_SOURCE == "client":
|
|
2319
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
2320
|
+
else:
|
|
2321
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
1650
2322
|
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
1651
2323
|
sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
|
|
1652
2324
|
email_sent = False
|
|
@@ -1663,11 +2335,13 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1663
2335
|
if platform.system() != 'Windows':
|
|
1664
2336
|
signal.alarm(0)
|
|
1665
2337
|
|
|
1666
|
-
|
|
2338
|
+
err = str(e).lower()
|
|
2339
|
+
|
|
2340
|
+
if TOKEN_SOURCE == 'cookie' and '401' in err:
|
|
1667
2341
|
SP_CACHED_ACCESS_TOKEN = None
|
|
1668
2342
|
|
|
1669
2343
|
str_matches = ["500 server", "504 server", "502 server", "503 server"]
|
|
1670
|
-
if any(x in
|
|
2344
|
+
if any(x in err for x in str_matches):
|
|
1671
2345
|
if not error_500_start_ts:
|
|
1672
2346
|
error_500_start_ts = int(time.time())
|
|
1673
2347
|
error_500_counter = 1
|
|
@@ -1675,7 +2349,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1675
2349
|
error_500_counter += 1
|
|
1676
2350
|
|
|
1677
2351
|
str_matches = ["timed out", "timeout", "name resolution", "failed to resolve", "family not supported", "429 client", "aborted"]
|
|
1678
|
-
if any(x in
|
|
2352
|
+
if any(x in err for x in str_matches) or str(e) == '':
|
|
1679
2353
|
if not error_network_issue_start_ts:
|
|
1680
2354
|
error_network_issue_start_ts = int(time.time())
|
|
1681
2355
|
error_network_issue_counter = 1
|
|
@@ -1696,7 +2370,21 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1696
2370
|
|
|
1697
2371
|
elif not error_500_start_ts and not error_network_issue_start_ts:
|
|
1698
2372
|
print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: '{e}'")
|
|
1699
|
-
|
|
2373
|
+
|
|
2374
|
+
client_errs = ['access token', 'invalid client token', 'expired client token']
|
|
2375
|
+
cookie_errs = ['access token', 'unsuccessful token request']
|
|
2376
|
+
|
|
2377
|
+
if TOKEN_SOURCE == 'client' and any(k in err for k in client_errs):
|
|
2378
|
+
print(f"* Error: client token or refresh token might have expired!")
|
|
2379
|
+
if ERROR_NOTIFICATION and not email_sent:
|
|
2380
|
+
m_subject = f"spotify_monitor: client token or refresh token might have expired! (uri: {user_uri_id})"
|
|
2381
|
+
m_body = f"Client token or refresh token might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
|
|
2382
|
+
m_body_html = f"<html><head></head><body>Client token or refresh token might have expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
|
|
2383
|
+
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
2384
|
+
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
2385
|
+
email_sent = True
|
|
2386
|
+
|
|
2387
|
+
elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
|
|
1700
2388
|
print(f"* Error: sp_dc might have expired!")
|
|
1701
2389
|
if ERROR_NOTIFICATION and not email_sent:
|
|
1702
2390
|
m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
|
|
@@ -1705,6 +2393,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1705
2393
|
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
1706
2394
|
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
1707
2395
|
email_sent = True
|
|
2396
|
+
|
|
1708
2397
|
print_cur_ts("Timestamp:\t\t\t")
|
|
1709
2398
|
time.sleep(SPOTIFY_ERROR_INTERVAL)
|
|
1710
2399
|
|
|
@@ -2036,7 +2725,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
2036
2725
|
|
|
2037
2726
|
|
|
2038
2727
|
def main():
|
|
2039
|
-
global CLI_CONFIG_PATH, DOTENV_FILE, LIVENESS_CHECK_COUNTER, SP_DC_COOKIE, CSV_FILE, MONITOR_LIST_FILE, FILE_SUFFIX, DISABLE_LOGGING, SP_LOGFILE, ACTIVE_NOTIFICATION, INACTIVE_NOTIFICATION, TRACK_NOTIFICATION, SONG_NOTIFICATION, SONG_ON_LOOP_NOTIFICATION, ERROR_NOTIFICATION, SPOTIFY_CHECK_INTERVAL, SPOTIFY_INACTIVITY_CHECK, SPOTIFY_ERROR_INTERVAL, SPOTIFY_DISAPPEARED_CHECK_INTERVAL, TRACK_SONGS, SMTP_PASSWORD, stdout_bck
|
|
2728
|
+
global CLI_CONFIG_PATH, DOTENV_FILE, LIVENESS_CHECK_COUNTER, LOGIN_REQUEST_BODY_FILE, CLIENTTOKEN_REQUEST_BODY_FILE, REFRESH_TOKEN, LOGIN_URL, USER_AGENT, DEVICE_ID, SYSTEM_ID, USER_URI_ID, SP_DC_COOKIE, CSV_FILE, MONITOR_LIST_FILE, FILE_SUFFIX, DISABLE_LOGGING, SP_LOGFILE, ACTIVE_NOTIFICATION, INACTIVE_NOTIFICATION, TRACK_NOTIFICATION, SONG_NOTIFICATION, SONG_ON_LOOP_NOTIFICATION, ERROR_NOTIFICATION, SPOTIFY_CHECK_INTERVAL, SPOTIFY_INACTIVITY_CHECK, SPOTIFY_ERROR_INTERVAL, SPOTIFY_DISAPPEARED_CHECK_INTERVAL, TRACK_SONGS, SMTP_PASSWORD, stdout_bck, APP_VERSION, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR, CLIENT_MODEL, TOKEN_SOURCE, ALARM_TIMEOUT, pyotp
|
|
2040
2729
|
|
|
2041
2730
|
if "--generate-config" in sys.argv:
|
|
2042
2731
|
print(CONFIG_BLOCK.strip("\n"))
|
|
@@ -2065,7 +2754,8 @@ def main():
|
|
|
2065
2754
|
"user_id",
|
|
2066
2755
|
nargs="?",
|
|
2067
2756
|
metavar="SPOTIFY_USER_URI_ID",
|
|
2068
|
-
help="Spotify user URI ID"
|
|
2757
|
+
help="Spotify user URI ID",
|
|
2758
|
+
type=str
|
|
2069
2759
|
)
|
|
2070
2760
|
|
|
2071
2761
|
# Version, just to list in help, it is handled earlier
|
|
@@ -2095,15 +2785,40 @@ def main():
|
|
|
2095
2785
|
help="Path to optional dotenv file (auto-search if not set, disable with 'none')",
|
|
2096
2786
|
)
|
|
2097
2787
|
|
|
2098
|
-
#
|
|
2099
|
-
|
|
2100
|
-
|
|
2788
|
+
# Token source
|
|
2789
|
+
parser.add_argument(
|
|
2790
|
+
"--token-source",
|
|
2791
|
+
dest="token_source",
|
|
2792
|
+
choices=["cookie", "client"],
|
|
2793
|
+
help="Method to obtain Spotify access token: 'cookie' (via sp_dc cookie) or 'client' (via desktop client login protobuf)"
|
|
2794
|
+
)
|
|
2795
|
+
|
|
2796
|
+
# Cookie token source credentials (used when token source is set to cookie)
|
|
2797
|
+
api_creds = parser.add_argument_group("Cookie token source credentials")
|
|
2798
|
+
api_creds.add_argument(
|
|
2101
2799
|
"-u", "--spotify-dc-cookie",
|
|
2102
2800
|
dest="spotify_dc_cookie",
|
|
2103
2801
|
metavar="SP_DC_COOKIE",
|
|
2802
|
+
type=str,
|
|
2104
2803
|
help="Spotify sp_dc cookie"
|
|
2105
2804
|
)
|
|
2106
2805
|
|
|
2806
|
+
# Client token source credentials (used when token source is set to client)
|
|
2807
|
+
client_creds = parser.add_argument_group("Client token source credentials")
|
|
2808
|
+
client_creds.add_argument(
|
|
2809
|
+
"-w", "--login-request-body-file",
|
|
2810
|
+
dest="login_request_body_file",
|
|
2811
|
+
metavar="PROTOBUF_FILENAME",
|
|
2812
|
+
help="Read device_id, system_id, user_uri_id and refresh_token from binary Protobuf login file"
|
|
2813
|
+
)
|
|
2814
|
+
|
|
2815
|
+
client_creds.add_argument(
|
|
2816
|
+
"-z", "--clienttoken-request-body-file",
|
|
2817
|
+
dest="clienttoken_request_body_file",
|
|
2818
|
+
metavar="PROTOBUF_FILENAME",
|
|
2819
|
+
help="Read app_version, cpu_arch, os_build, platform, os_major, os_minor and client_model from binary Protobuf client token file"
|
|
2820
|
+
)
|
|
2821
|
+
|
|
2107
2822
|
# Notifications
|
|
2108
2823
|
notify = parser.add_argument_group("Notifications")
|
|
2109
2824
|
notify.add_argument(
|
|
@@ -2146,7 +2861,7 @@ def main():
|
|
|
2146
2861
|
dest="notify_errors",
|
|
2147
2862
|
action="store_false",
|
|
2148
2863
|
default=None,
|
|
2149
|
-
help="Disable
|
|
2864
|
+
help="Disable emails on errors"
|
|
2150
2865
|
)
|
|
2151
2866
|
notify.add_argument(
|
|
2152
2867
|
"--send-test-email",
|
|
@@ -2229,7 +2944,7 @@ def main():
|
|
|
2229
2944
|
dest="disable_logging",
|
|
2230
2945
|
action="store_true",
|
|
2231
2946
|
default=None,
|
|
2232
|
-
help="Disable logging to
|
|
2947
|
+
help="Disable logging to spotify_monitor_<user_uri_id/file_suffix>.log"
|
|
2233
2948
|
)
|
|
2234
2949
|
|
|
2235
2950
|
args = parser.parse_args()
|
|
@@ -2288,6 +3003,19 @@ def main():
|
|
|
2288
3003
|
if val is not None:
|
|
2289
3004
|
globals()[secret] = val
|
|
2290
3005
|
|
|
3006
|
+
if args.token_source:
|
|
3007
|
+
TOKEN_SOURCE = args.token_source
|
|
3008
|
+
|
|
3009
|
+
if not TOKEN_SOURCE:
|
|
3010
|
+
TOKEN_SOURCE = "cookie"
|
|
3011
|
+
|
|
3012
|
+
if TOKEN_SOURCE == "cookie":
|
|
3013
|
+
ALARM_TIMEOUT = int((TOKEN_MAX_RETRIES * TOKEN_RETRY_TIMEOUT) + 5)
|
|
3014
|
+
try:
|
|
3015
|
+
import pyotp
|
|
3016
|
+
except ModuleNotFoundError:
|
|
3017
|
+
raise SystemExit("Error: Couldn't find the pyotp library !\n\nTo install it, run:\n pip3 install pyotp\n\nOnce installed, re-run this tool")
|
|
3018
|
+
|
|
2291
3019
|
if not check_internet():
|
|
2292
3020
|
sys.exit(1)
|
|
2293
3021
|
|
|
@@ -2309,21 +3037,112 @@ def main():
|
|
|
2309
3037
|
if args.disappeared_timer:
|
|
2310
3038
|
SPOTIFY_DISAPPEARED_CHECK_INTERVAL = args.disappeared_timer
|
|
2311
3039
|
|
|
2312
|
-
if
|
|
2313
|
-
|
|
3040
|
+
if TOKEN_SOURCE == "client":
|
|
3041
|
+
login_request_body_file_param = False
|
|
3042
|
+
if args.login_request_body_file:
|
|
3043
|
+
LOGIN_REQUEST_BODY_FILE = os.path.expanduser(args.login_request_body_file)
|
|
3044
|
+
login_request_body_file_param = True
|
|
3045
|
+
else:
|
|
3046
|
+
if LOGIN_REQUEST_BODY_FILE:
|
|
3047
|
+
LOGIN_REQUEST_BODY_FILE = os.path.expanduser(LOGIN_REQUEST_BODY_FILE)
|
|
2314
3048
|
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
3049
|
+
if LOGIN_REQUEST_BODY_FILE:
|
|
3050
|
+
if os.path.isfile(LOGIN_REQUEST_BODY_FILE):
|
|
3051
|
+
try:
|
|
3052
|
+
DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_request_body_file(LOGIN_REQUEST_BODY_FILE)
|
|
3053
|
+
except Exception as e:
|
|
3054
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
3055
|
+
sys.exit(1)
|
|
3056
|
+
else:
|
|
3057
|
+
if not args.user_id and not args.list_friends and not args.show_user_info and login_request_body_file_param:
|
|
3058
|
+
print(f"* Login data correctly read from Protobuf file ({LOGIN_REQUEST_BODY_FILE}):")
|
|
3059
|
+
print(" - Device ID:\t\t", DEVICE_ID)
|
|
3060
|
+
print(" - System ID:\t\t", SYSTEM_ID)
|
|
3061
|
+
print(" - User URI ID:\t\t", USER_URI_ID)
|
|
3062
|
+
print(" - Refresh Token:\t", REFRESH_TOKEN, "\n")
|
|
3063
|
+
sys.exit(0)
|
|
3064
|
+
else:
|
|
3065
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) does not exist")
|
|
3066
|
+
sys.exit(1)
|
|
3067
|
+
|
|
3068
|
+
if not USER_AGENT:
|
|
3069
|
+
USER_AGENT = get_random_spotify_user_agent()
|
|
3070
|
+
|
|
3071
|
+
if any([
|
|
3072
|
+
not LOGIN_URL,
|
|
3073
|
+
not USER_AGENT,
|
|
3074
|
+
not DEVICE_ID,
|
|
3075
|
+
DEVICE_ID == "your_spotify_app_device_id",
|
|
3076
|
+
not SYSTEM_ID,
|
|
3077
|
+
SYSTEM_ID == "your_spotify_app_system_id",
|
|
3078
|
+
not USER_URI_ID,
|
|
3079
|
+
USER_URI_ID == "your_spotify_user_uri_id",
|
|
3080
|
+
not REFRESH_TOKEN,
|
|
3081
|
+
REFRESH_TOKEN == "your_spotify_app_refresh_token",
|
|
3082
|
+
]):
|
|
3083
|
+
print("* Error: Some login values are empty or incorrect")
|
|
3084
|
+
sys.exit(1)
|
|
3085
|
+
|
|
3086
|
+
clienttoken_request_body_file_param = False
|
|
3087
|
+
if args.clienttoken_request_body_file:
|
|
3088
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = os.path.expanduser(args.clienttoken_request_body_file)
|
|
3089
|
+
clienttoken_request_body_file_param = True
|
|
3090
|
+
else:
|
|
3091
|
+
if CLIENTTOKEN_REQUEST_BODY_FILE:
|
|
3092
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = os.path.expanduser(CLIENTTOKEN_REQUEST_BODY_FILE)
|
|
3093
|
+
|
|
3094
|
+
if CLIENTTOKEN_REQUEST_BODY_FILE:
|
|
3095
|
+
if os.path.isfile(CLIENTTOKEN_REQUEST_BODY_FILE):
|
|
3096
|
+
try:
|
|
3097
|
+
|
|
3098
|
+
(APP_VERSION, _, _, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR, CLIENT_MODEL) = parse_clienttoken_request_body_file(CLIENTTOKEN_REQUEST_BODY_FILE)
|
|
3099
|
+
except Exception as e:
|
|
3100
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
3101
|
+
sys.exit(1)
|
|
3102
|
+
else:
|
|
3103
|
+
if not args.user_id and not args.list_friends and not args.show_user_info and clienttoken_request_body_file_param:
|
|
3104
|
+
print(f"* Client token data correctly read from Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}):")
|
|
3105
|
+
print(" - App version:\t\t", APP_VERSION)
|
|
3106
|
+
print(" - CPU arch:\t\t", CPU_ARCH)
|
|
3107
|
+
print(" - OS build:\t\t", OS_BUILD)
|
|
3108
|
+
print(" - Platform:\t\t", PLATFORM)
|
|
3109
|
+
print(" - OS major:\t\t", OS_MAJOR)
|
|
3110
|
+
print(" - OS minor:\t\t", OS_MINOR)
|
|
3111
|
+
print(" - Client model:\t", CLIENT_MODEL)
|
|
3112
|
+
sys.exit(0)
|
|
3113
|
+
else:
|
|
3114
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) does not exist")
|
|
3115
|
+
sys.exit(1)
|
|
3116
|
+
|
|
3117
|
+
app_version_default = "1.2.62.580.g7e3d9a4f"
|
|
3118
|
+
if USER_AGENT and not APP_VERSION:
|
|
3119
|
+
try:
|
|
3120
|
+
APP_VERSION = ua_to_app_version(USER_AGENT)
|
|
3121
|
+
except Exception as e:
|
|
3122
|
+
print(f"Warning: wrong USER_AGENT defined, reverting to the default one: {e}")
|
|
3123
|
+
APP_VERSION = app_version_default
|
|
3124
|
+
else:
|
|
3125
|
+
APP_VERSION = app_version_default
|
|
3126
|
+
|
|
3127
|
+
else:
|
|
3128
|
+
if args.spotify_dc_cookie:
|
|
3129
|
+
SP_DC_COOKIE = args.spotify_dc_cookie
|
|
3130
|
+
|
|
3131
|
+
if not SP_DC_COOKIE or SP_DC_COOKIE == "your_sp_dc_cookie_value":
|
|
3132
|
+
print("* Error: SP_DC_COOKIE (-u / --spotify_dc_cookie) value is empty or incorrect")
|
|
3133
|
+
sys.exit(1)
|
|
2318
3134
|
|
|
2319
3135
|
if args.show_user_info:
|
|
2320
3136
|
print("* Getting basic information about access token owner ...\n")
|
|
2321
3137
|
try:
|
|
2322
|
-
|
|
2323
|
-
|
|
3138
|
+
if TOKEN_SOURCE == "client":
|
|
3139
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
3140
|
+
else:
|
|
3141
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
3142
|
+
user_info = spotify_get_current_user(sp_accessToken)
|
|
2324
3143
|
|
|
2325
3144
|
if user_info:
|
|
2326
|
-
print(f"Token belongs to:\n")
|
|
3145
|
+
print(f"Token fetched via {TOKEN_SOURCE} belongs to:\n")
|
|
2327
3146
|
|
|
2328
3147
|
print(f"Username:\t\t{user_info.get('display_name', '')}")
|
|
2329
3148
|
print(f"User URI ID:\t\t{user_info.get('uri', '').split('spotify:user:', 1)[1]}")
|
|
@@ -2343,11 +3162,14 @@ def main():
|
|
|
2343
3162
|
if args.list_friends:
|
|
2344
3163
|
print("* Listing Spotify friends ...\n")
|
|
2345
3164
|
try:
|
|
2346
|
-
|
|
2347
|
-
|
|
3165
|
+
if TOKEN_SOURCE == "client":
|
|
3166
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
3167
|
+
else:
|
|
3168
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
3169
|
+
user_info = spotify_get_current_user(sp_accessToken)
|
|
2348
3170
|
if user_info:
|
|
2349
|
-
print(f"Token belongs to:\t\t{user_info.get('display_name', '')}\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
|
|
2350
|
-
sp_friends = spotify_get_friends_json(
|
|
3171
|
+
print(f"Token belongs to:\t\t{user_info.get('display_name', '')} (via {TOKEN_SOURCE})\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
|
|
3172
|
+
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
2351
3173
|
spotify_list_friends(sp_friends)
|
|
2352
3174
|
print("─" * HORIZONTAL_LINE)
|
|
2353
3175
|
except Exception as e:
|
|
@@ -2453,6 +3275,7 @@ def main():
|
|
|
2453
3275
|
|
|
2454
3276
|
print(f"* Spotify polling intervals:\t[check: {display_time(SPOTIFY_CHECK_INTERVAL)}] [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]\n\t\t\t\t[disappeared: {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)}] [error: {display_time(SPOTIFY_ERROR_INTERVAL)}]")
|
|
2455
3277
|
print(f"* Email notifications:\t\t[active = {ACTIVE_NOTIFICATION}] [inactive = {INACTIVE_NOTIFICATION}] [tracked = {TRACK_NOTIFICATION}]\n*\t\t\t\t[songs on loop = {SONG_ON_LOOP_NOTIFICATION}] [every song = {SONG_NOTIFICATION}] [errors = {ERROR_NOTIFICATION}]")
|
|
3278
|
+
print(f"* Token source:\t\t\t{TOKEN_SOURCE}")
|
|
2456
3279
|
print(f"* Track listened songs:\t\t{TRACK_SONGS}")
|
|
2457
3280
|
print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
|
|
2458
3281
|
print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
|