spotify-monitor 2.0rc2__py3-none-any.whl → 2.1.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.
Potentially problematic release.
This version of spotify-monitor might be problematic. Click here for more details.
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.1.dist-info}/METADATA +67 -7
- spotify_monitor-2.1.1.dist-info/RECORD +7 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.1.dist-info}/WHEEL +1 -1
- spotify_monitor.py +940 -106
- spotify_monitor-2.0rc2.dist-info/RECORD +0 -7
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.1.dist-info}/entry_points.txt +0 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.1.dist-info}/licenses/LICENSE +0 -0
- {spotify_monitor-2.0rc2.dist-info → spotify_monitor-2.1.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.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.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
|
#
|
|
@@ -97,7 +109,7 @@ SPOTIFY_INACTIVITY_CHECK = 660 # 11 mins
|
|
|
97
109
|
# Can also be set using the -m flag
|
|
98
110
|
SPOTIFY_DISAPPEARED_CHECK_INTERVAL = 180 # 3 mins
|
|
99
111
|
|
|
100
|
-
# Whether to auto
|
|
112
|
+
# Whether to auto-play each listened song in your Spotify client
|
|
101
113
|
# Can also be set using the -g flag
|
|
102
114
|
TRACK_SONGS = False
|
|
103
115
|
|
|
@@ -190,7 +202,7 @@ SP_LOGFILE = "spotify_monitor"
|
|
|
190
202
|
# Can also be disabled via the -d flag
|
|
191
203
|
DISABLE_LOGGING = False
|
|
192
204
|
|
|
193
|
-
# Width of horizontal line
|
|
205
|
+
# Width of horizontal line
|
|
194
206
|
HORIZONTAL_LINE = 113
|
|
195
207
|
|
|
196
208
|
# Whether to clear the terminal screen after starting the tool
|
|
@@ -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:
|
|
@@ -398,7 +512,7 @@ def signal_handler(sig, frame):
|
|
|
398
512
|
# Checks internet connectivity
|
|
399
513
|
def check_internet(url=CHECK_INTERNET_URL, timeout=CHECK_INTERNET_TIMEOUT, verify=VERIFY_SSL):
|
|
400
514
|
try:
|
|
401
|
-
_ = req.get(url, timeout=timeout, verify=verify)
|
|
515
|
+
_ = req.get(url, headers={'User-Agent': get_random_user_agent() if TOKEN_SOURCE == 'cookie' else get_random_spotify_user_agent()}, timeout=timeout, verify=verify)
|
|
402
516
|
return True
|
|
403
517
|
except req.RequestException as e:
|
|
404
518
|
print(f"* No connectivity, please check your network:\n\n{e}")
|
|
@@ -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,39 @@ 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 user_agent is not None:
|
|
986
|
+
headers.update({
|
|
987
|
+
"User-Agent": user_agent
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
if TOKEN_SOURCE == "cookie" and client_id is not None:
|
|
991
|
+
headers.update({
|
|
992
|
+
"Client-Id": client_id
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
if platform.system() != 'Windows':
|
|
996
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
997
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
998
|
+
try:
|
|
999
|
+
response = req.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1000
|
+
valid = response.status_code == 200
|
|
1001
|
+
except Exception:
|
|
1002
|
+
valid = False
|
|
1003
|
+
finally:
|
|
1004
|
+
if platform.system() != 'Windows':
|
|
1005
|
+
signal.alarm(0)
|
|
1006
|
+
return valid
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# -------------------------------------------------------
|
|
1010
|
+
# Supporting functions when token source is set to cookie
|
|
1011
|
+
# -------------------------------------------------------
|
|
1012
|
+
|
|
823
1013
|
# Returns random user agent string
|
|
824
1014
|
def get_random_user_agent() -> str:
|
|
825
1015
|
browser = random.choice(['chrome', 'firefox', 'edge', 'safari'])
|
|
@@ -909,6 +1099,8 @@ def hex_to_bytes(data: str) -> bytes:
|
|
|
909
1099
|
|
|
910
1100
|
# Creates a TOTP object using a secret derived from transformed cipher bytes
|
|
911
1101
|
def generate_totp(ua: str):
|
|
1102
|
+
import pyotp
|
|
1103
|
+
|
|
912
1104
|
secret_cipher_bytes = [
|
|
913
1105
|
12, 56, 76, 33, 88, 44, 88, 33,
|
|
914
1106
|
78, 78, 11, 66, 22, 22, 55, 69, 54,
|
|
@@ -951,29 +1143,6 @@ def generate_totp(ua: str):
|
|
|
951
1143
|
return totp_obj, server_time
|
|
952
1144
|
|
|
953
1145
|
|
|
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
1146
|
# Retrieves a new Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
|
|
978
1147
|
def refresh_token(sp_dc: str) -> dict:
|
|
979
1148
|
transport = True
|
|
@@ -1052,12 +1221,12 @@ def refresh_token(sp_dc: str) -> dict:
|
|
|
1052
1221
|
|
|
1053
1222
|
|
|
1054
1223
|
# Fetches Spotify access token based on provided SP_DC value
|
|
1055
|
-
def
|
|
1056
|
-
global SP_CACHED_ACCESS_TOKEN,
|
|
1224
|
+
def spotify_get_access_token_from_sp_dc(sp_dc: str):
|
|
1225
|
+
global SP_CACHED_ACCESS_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT
|
|
1057
1226
|
|
|
1058
1227
|
now = time.time()
|
|
1059
1228
|
|
|
1060
|
-
if SP_CACHED_ACCESS_TOKEN and now <
|
|
1229
|
+
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
1230
|
return SP_CACHED_ACCESS_TOKEN
|
|
1062
1231
|
|
|
1063
1232
|
max_retries = TOKEN_MAX_RETRIES
|
|
@@ -1071,7 +1240,7 @@ def spotify_get_access_token(sp_dc: str):
|
|
|
1071
1240
|
length = token_data["length"]
|
|
1072
1241
|
|
|
1073
1242
|
SP_CACHED_ACCESS_TOKEN = token
|
|
1074
|
-
|
|
1243
|
+
SP_ACCESS_TOKEN_EXPIRES_AT = token_data["expires_at"]
|
|
1075
1244
|
SP_CACHED_CLIENT_ID = client_id
|
|
1076
1245
|
SP_CACHED_USER_AGENT = user_agent
|
|
1077
1246
|
|
|
@@ -1096,14 +1265,490 @@ def spotify_get_access_token(sp_dc: str):
|
|
|
1096
1265
|
return SP_CACHED_ACCESS_TOKEN
|
|
1097
1266
|
|
|
1098
1267
|
|
|
1268
|
+
# -------------------------------------------------------
|
|
1269
|
+
# Supporting functions when token source is set to client
|
|
1270
|
+
# -------------------------------------------------------
|
|
1271
|
+
|
|
1272
|
+
# Returns random Spotify client user agent string
|
|
1273
|
+
def get_random_spotify_user_agent() -> str:
|
|
1274
|
+
os_choice = random.choice(['windows', 'mac', 'linux'])
|
|
1275
|
+
|
|
1276
|
+
if os_choice == 'windows':
|
|
1277
|
+
build = random.randint(120000000, 130000000)
|
|
1278
|
+
arch = random.choice(['Win32', 'Win32_x86_64'])
|
|
1279
|
+
device = random.choice(['desktop', 'laptop'])
|
|
1280
|
+
return f"Spotify/{build} {arch}/0 (PC {device})"
|
|
1281
|
+
|
|
1282
|
+
elif os_choice == 'mac':
|
|
1283
|
+
build = random.randint(120000000, 130000000)
|
|
1284
|
+
arch = random.choice(['OSX_ARM64', 'OSX_X86_64'])
|
|
1285
|
+
major = random.randint(10, 15)
|
|
1286
|
+
minor = random.randint(0, 7)
|
|
1287
|
+
patch = random.randint(0, 5)
|
|
1288
|
+
os_version = f"OS X {major}.{minor}.{patch}"
|
|
1289
|
+
if arch == 'OSX_ARM64':
|
|
1290
|
+
bracket = f"[arm {random.randint(1, 3)}]"
|
|
1291
|
+
else:
|
|
1292
|
+
bracket = "[x86_64]"
|
|
1293
|
+
return f"Spotify/{build} {arch}/{os_version} {bracket}"
|
|
1294
|
+
|
|
1295
|
+
else: # linux
|
|
1296
|
+
build = random.randint(120000000, 130000000)
|
|
1297
|
+
arch = random.choice(['Linux; x86_64', 'Linux; x86'])
|
|
1298
|
+
return f"Spotify/{build} ({arch})"
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
# Encodes an integer using Protobuf varint format
|
|
1302
|
+
def encode_varint(value):
|
|
1303
|
+
result = bytearray()
|
|
1304
|
+
while value > 0x7F:
|
|
1305
|
+
result.append((value & 0x7F) | 0x80)
|
|
1306
|
+
value //= 128
|
|
1307
|
+
result.append(value)
|
|
1308
|
+
return bytes(result)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
# Encodes a string field with the given tag
|
|
1312
|
+
def encode_string_field(tag, value):
|
|
1313
|
+
key = encode_varint((tag << 3) | 2) # wire type 2 (length-delimited)
|
|
1314
|
+
value_bytes = value.encode('utf-8')
|
|
1315
|
+
length = encode_varint(len(value_bytes))
|
|
1316
|
+
return key + length + value_bytes
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
# Encodes a nested message field with the given tag
|
|
1320
|
+
def encode_nested_field(tag, nested_bytes):
|
|
1321
|
+
key = encode_varint((tag << 3) | 2)
|
|
1322
|
+
length = encode_varint(len(nested_bytes))
|
|
1323
|
+
return key + length + nested_bytes
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
# Builds the Spotify Protobuf login request body
|
|
1327
|
+
def build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token):
|
|
1328
|
+
"""
|
|
1329
|
+
1 {
|
|
1330
|
+
1: "device_id"
|
|
1331
|
+
2: "system_id"
|
|
1332
|
+
}
|
|
1333
|
+
100 {
|
|
1334
|
+
1: "user_uri_id"
|
|
1335
|
+
2: "refresh_token"
|
|
1336
|
+
}
|
|
1337
|
+
"""
|
|
1338
|
+
device_info_msg = encode_string_field(1, device_id) + encode_string_field(2, system_id)
|
|
1339
|
+
field_device_info = encode_nested_field(1, device_info_msg)
|
|
1340
|
+
|
|
1341
|
+
user_auth_msg = encode_string_field(1, user_uri_id) + encode_string_field(2, refresh_token)
|
|
1342
|
+
field_user_auth = encode_nested_field(100, user_auth_msg)
|
|
1343
|
+
|
|
1344
|
+
return field_device_info + field_user_auth
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
# Reads a varint from data starting at index
|
|
1348
|
+
def read_varint(data, index):
|
|
1349
|
+
shift = 0
|
|
1350
|
+
result = 0
|
|
1351
|
+
bytes_read = 0
|
|
1352
|
+
while True:
|
|
1353
|
+
b = data[index]
|
|
1354
|
+
result |= ((b & 0x7F) << shift)
|
|
1355
|
+
bytes_read += 1
|
|
1356
|
+
index += 1
|
|
1357
|
+
if not (b & 0x80):
|
|
1358
|
+
break
|
|
1359
|
+
shift += 7
|
|
1360
|
+
return result, bytes_read
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
# Parses Spotify Protobuf login response
|
|
1364
|
+
def parse_protobuf_message(data):
|
|
1365
|
+
index = 0
|
|
1366
|
+
result = {}
|
|
1367
|
+
while index < len(data):
|
|
1368
|
+
try:
|
|
1369
|
+
key, key_len = read_varint(data, index)
|
|
1370
|
+
except IndexError:
|
|
1371
|
+
break
|
|
1372
|
+
index += key_len
|
|
1373
|
+
tag = key >> 3
|
|
1374
|
+
wire_type = key & 0x07
|
|
1375
|
+
if wire_type == 2: # length-delimited
|
|
1376
|
+
length, len_len = read_varint(data, index)
|
|
1377
|
+
index += len_len
|
|
1378
|
+
raw_value = data[index:index + length]
|
|
1379
|
+
index += length
|
|
1380
|
+
# If the first byte is a control character (e.g. 0x0A) assume nested
|
|
1381
|
+
if raw_value and raw_value[0] < 0x20:
|
|
1382
|
+
value = parse_protobuf_message(raw_value)
|
|
1383
|
+
else:
|
|
1384
|
+
try:
|
|
1385
|
+
value = raw_value.decode('utf-8')
|
|
1386
|
+
except UnicodeDecodeError:
|
|
1387
|
+
value = raw_value
|
|
1388
|
+
result[tag] = value
|
|
1389
|
+
elif wire_type == 0: # varint
|
|
1390
|
+
value, var_len = read_varint(data, index)
|
|
1391
|
+
index += var_len
|
|
1392
|
+
result[tag] = value
|
|
1393
|
+
else:
|
|
1394
|
+
break
|
|
1395
|
+
return result # dictionary mapping tags to values
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
# Parses the Protobuf-encoded login request body file (as dumped for example by Proxyman) and returns a tuple:
|
|
1399
|
+
# (device_id, system_id, user_uri_id, refresh_token)
|
|
1400
|
+
def parse_request_body_file(file_path):
|
|
1401
|
+
"""
|
|
1402
|
+
{
|
|
1403
|
+
1: {
|
|
1404
|
+
1: "device_id",
|
|
1405
|
+
2: "system_id"
|
|
1406
|
+
},
|
|
1407
|
+
100: {
|
|
1408
|
+
1: "user_uri_id",
|
|
1409
|
+
2: "refresh_token"
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
"""
|
|
1413
|
+
with open(file_path, "rb") as f:
|
|
1414
|
+
data = f.read()
|
|
1415
|
+
parsed = parse_protobuf_message(data)
|
|
1416
|
+
|
|
1417
|
+
device_id = None
|
|
1418
|
+
system_id = None
|
|
1419
|
+
user_uri_id = None
|
|
1420
|
+
refresh_token = None
|
|
1421
|
+
|
|
1422
|
+
if 1 in parsed:
|
|
1423
|
+
device_info = parsed[1]
|
|
1424
|
+
if isinstance(device_info, dict):
|
|
1425
|
+
device_id = device_info.get(1)
|
|
1426
|
+
system_id = device_info.get(2)
|
|
1427
|
+
else:
|
|
1428
|
+
pass
|
|
1429
|
+
|
|
1430
|
+
if 100 in parsed:
|
|
1431
|
+
user_auth = parsed[100]
|
|
1432
|
+
if isinstance(user_auth, dict):
|
|
1433
|
+
user_uri_id = user_auth.get(1)
|
|
1434
|
+
refresh_token = user_auth.get(2)
|
|
1435
|
+
|
|
1436
|
+
protobuf_fields = {
|
|
1437
|
+
"device_id": device_id,
|
|
1438
|
+
"system_id": system_id,
|
|
1439
|
+
"user_uri_id": user_uri_id,
|
|
1440
|
+
"refresh_token": refresh_token,
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
protobuf_missing_fields = [name for name, value in protobuf_fields.items() if value is None]
|
|
1444
|
+
|
|
1445
|
+
if protobuf_missing_fields:
|
|
1446
|
+
missing_str = ", ".join(protobuf_missing_fields)
|
|
1447
|
+
raise Exception(f"Following fields could not be extracted: {missing_str}")
|
|
1448
|
+
|
|
1449
|
+
return device_id, system_id, user_uri_id, refresh_token
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
# Recursively flattens nested dictionaries or lists into a single string
|
|
1453
|
+
def deep_flatten(value):
|
|
1454
|
+
if isinstance(value, dict):
|
|
1455
|
+
return "".join(deep_flatten(v) for k, v in sorted(value.items()))
|
|
1456
|
+
elif isinstance(value, list):
|
|
1457
|
+
return "".join(deep_flatten(item) for item in value)
|
|
1458
|
+
else:
|
|
1459
|
+
return str(value)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
# Returns the input if it's a dict, parses as Protobuf it if it's bytes or returns an empty dict otherwise
|
|
1463
|
+
def ensure_dict(value):
|
|
1464
|
+
if isinstance(value, dict):
|
|
1465
|
+
return value
|
|
1466
|
+
if isinstance(value, (bytes, bytearray)):
|
|
1467
|
+
try:
|
|
1468
|
+
return parse_protobuf_message(value)
|
|
1469
|
+
except Exception:
|
|
1470
|
+
return {}
|
|
1471
|
+
return {}
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
# Parses the Protobuf-encoded client token request body file (as dumped for example by Proxyman) and returns a tuple:
|
|
1475
|
+
# (app_version, device_id, system_id, cpu_arch, os_build, platform, os_major, os_minor, client_model)
|
|
1476
|
+
def parse_clienttoken_request_body_file(file_path):
|
|
1477
|
+
|
|
1478
|
+
with open(file_path, "rb") as f:
|
|
1479
|
+
data = f.read()
|
|
1480
|
+
|
|
1481
|
+
root = ensure_dict(parse_protobuf_message(data).get(2))
|
|
1482
|
+
|
|
1483
|
+
app_version = root.get(1)
|
|
1484
|
+
device_id = root.get(2)
|
|
1485
|
+
|
|
1486
|
+
nested_3 = ensure_dict(root.get(3))
|
|
1487
|
+
nested_1 = ensure_dict(nested_3.get(1))
|
|
1488
|
+
nested_4 = ensure_dict(nested_1.get(4))
|
|
1489
|
+
|
|
1490
|
+
cpu_arch = nested_4.get(1)
|
|
1491
|
+
os_build = nested_4.get(3)
|
|
1492
|
+
platform = nested_4.get(4)
|
|
1493
|
+
os_major = nested_4.get(5)
|
|
1494
|
+
os_minor = nested_4.get(6)
|
|
1495
|
+
client_model = nested_4.get(8)
|
|
1496
|
+
|
|
1497
|
+
system_id = nested_3.get(2)
|
|
1498
|
+
|
|
1499
|
+
required = {
|
|
1500
|
+
"app_version": app_version,
|
|
1501
|
+
"device_id": device_id,
|
|
1502
|
+
"system_id": system_id,
|
|
1503
|
+
}
|
|
1504
|
+
missing = [k for k, v in required.items() if v is None]
|
|
1505
|
+
if missing:
|
|
1506
|
+
raise Exception(f"Could not extract fields: {', '.join(missing)}")
|
|
1507
|
+
|
|
1508
|
+
return (app_version, device_id, system_id, cpu_arch, os_build, platform, os_major, os_minor, client_model)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
# Converts Spotify user agent string to Protobuf app_version string
|
|
1512
|
+
# For example: 'Spotify/126200580 Win32_x86_64/0 (PC desktop)' to '1.2.62.580.g<random-hex>'
|
|
1513
|
+
def ua_to_app_version(user_agent: str) -> str:
|
|
1514
|
+
|
|
1515
|
+
m = re.search(r"Spotify/(\d{5,})", user_agent)
|
|
1516
|
+
if not m:
|
|
1517
|
+
raise ValueError(f"User-Agent missing build number: {user_agent!r}")
|
|
1518
|
+
|
|
1519
|
+
digits = m.group(1)
|
|
1520
|
+
if len(digits) < 5:
|
|
1521
|
+
raise ValueError(f"Build number too short: {digits}")
|
|
1522
|
+
|
|
1523
|
+
major = digits[0]
|
|
1524
|
+
minor = digits[1]
|
|
1525
|
+
patch = str(int(digits[2:4]))
|
|
1526
|
+
build = str(int(digits[4:]))
|
|
1527
|
+
suffix = secrets.token_hex(4)
|
|
1528
|
+
|
|
1529
|
+
return f"{major}.{minor}.{patch}.{build}.g{suffix}"
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
# Builds the Protobuf client token request body
|
|
1533
|
+
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):
|
|
1534
|
+
"""
|
|
1535
|
+
1: 1 (const)
|
|
1536
|
+
2: {
|
|
1537
|
+
1: app_version
|
|
1538
|
+
2: device_id
|
|
1539
|
+
3: {
|
|
1540
|
+
1: {
|
|
1541
|
+
4: device_details
|
|
1542
|
+
}
|
|
1543
|
+
2: system_id
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
"""
|
|
1547
|
+
|
|
1548
|
+
leaf = (
|
|
1549
|
+
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))
|
|
1550
|
+
|
|
1551
|
+
msg_4 = encode_nested_field(4, leaf)
|
|
1552
|
+
msg_1 = encode_nested_field(1, msg_4)
|
|
1553
|
+
msg_3 = msg_1 + encode_string_field(2, system_id)
|
|
1554
|
+
|
|
1555
|
+
payload = (encode_string_field(1, app_version) + encode_string_field(2, device_id) + encode_nested_field(3, msg_3))
|
|
1556
|
+
|
|
1557
|
+
root = (encode_varint((1 << 3) | 0) + encode_varint(1) + encode_nested_field(2, payload))
|
|
1558
|
+
|
|
1559
|
+
return root
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
# Fetches Spotify access token based on provided device_id, system_id, user_uri_id, refresh_token and client_token value
|
|
1563
|
+
def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token):
|
|
1564
|
+
global SP_CACHED_ACCESS_TOKEN, SP_CACHED_REFRESH_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT
|
|
1565
|
+
|
|
1566
|
+
if SP_CACHED_ACCESS_TOKEN and time.time() < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, user_agent=USER_AGENT):
|
|
1567
|
+
return SP_CACHED_ACCESS_TOKEN
|
|
1568
|
+
|
|
1569
|
+
if not client_token:
|
|
1570
|
+
raise Exception("Client token is missing")
|
|
1571
|
+
|
|
1572
|
+
if SP_CACHED_REFRESH_TOKEN:
|
|
1573
|
+
refresh_token = SP_CACHED_REFRESH_TOKEN
|
|
1574
|
+
|
|
1575
|
+
protobuf_body = build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token)
|
|
1576
|
+
|
|
1577
|
+
parsed_url = urlparse(LOGIN_URL)
|
|
1578
|
+
host = parsed_url.netloc
|
|
1579
|
+
origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
|
1580
|
+
|
|
1581
|
+
headers = {
|
|
1582
|
+
"Host": host,
|
|
1583
|
+
"Connection": "keep-alive",
|
|
1584
|
+
"Content-Type": "application/x-protobuf",
|
|
1585
|
+
"User-Agent": USER_AGENT,
|
|
1586
|
+
"X-Retry-Count": "0",
|
|
1587
|
+
"Client-Token": client_token,
|
|
1588
|
+
"Origin": origin,
|
|
1589
|
+
"Accept-Language": "en-Latn-GB,en-GB;q=0.9,en;q=0.8",
|
|
1590
|
+
"Sec-Fetch-Site": "same-origin",
|
|
1591
|
+
"Sec-Fetch-Mode": "no-cors",
|
|
1592
|
+
"Sec-Fetch-Dest": "empty",
|
|
1593
|
+
"Accept-Encoding": "gzip, deflate, br, zstd"
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
try:
|
|
1597
|
+
if platform.system() != 'Windows':
|
|
1598
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1599
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1600
|
+
response = req.post(LOGIN_URL, headers=headers, data=protobuf_body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1601
|
+
except (req.RequestException, TimeoutException) as e:
|
|
1602
|
+
raise Exception(f"spotify_get_access_token_from_client() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
|
|
1603
|
+
finally:
|
|
1604
|
+
if platform.system() != 'Windows':
|
|
1605
|
+
signal.alarm(0)
|
|
1606
|
+
|
|
1607
|
+
if response.status_code != 200:
|
|
1608
|
+
if response.headers.get("client-token-error") == "INVALID_CLIENTTOKEN":
|
|
1609
|
+
raise Exception(f"Request failed with status {response.status_code}: invalid client token")
|
|
1610
|
+
elif response.headers.get("client-token-error") == "EXPIRED_CLIENTTOKEN":
|
|
1611
|
+
raise Exception(f"Request failed with status {response.status_code}: expired client token")
|
|
1612
|
+
|
|
1613
|
+
raise Exception(f"Request failed with status code {response.status_code}\nResponse Headers: {response.headers}\nResponse Content (raw): {response.content}\nResponse text: {response.text}")
|
|
1614
|
+
|
|
1615
|
+
parsed = parse_protobuf_message(response.content)
|
|
1616
|
+
# {1: {1: user_uri_id, 2: access_token, 3: refresh_token, 4: expires_in}}
|
|
1617
|
+
access_token_raw = None
|
|
1618
|
+
expires_in = 3600 # default
|
|
1619
|
+
if 1 in parsed and isinstance(parsed[1], dict):
|
|
1620
|
+
nested = parsed[1]
|
|
1621
|
+
access_token_raw = nested.get(2)
|
|
1622
|
+
user_uri_id = parsed[1].get(1)
|
|
1623
|
+
|
|
1624
|
+
if 4 in nested:
|
|
1625
|
+
raw_expires = nested.get(4)
|
|
1626
|
+
if isinstance(raw_expires, (int, str, bytes)):
|
|
1627
|
+
try:
|
|
1628
|
+
expires_in = int(raw_expires)
|
|
1629
|
+
except ValueError:
|
|
1630
|
+
expires_in = 3600
|
|
1631
|
+
|
|
1632
|
+
access_token = deep_flatten(access_token_raw) if access_token_raw else None
|
|
1633
|
+
|
|
1634
|
+
if not access_token:
|
|
1635
|
+
raise Exception("Access token not found in response")
|
|
1636
|
+
|
|
1637
|
+
SP_CACHED_ACCESS_TOKEN = access_token
|
|
1638
|
+
SP_CACHED_REFRESH_TOKEN = parsed[1].get(3)
|
|
1639
|
+
SP_ACCESS_TOKEN_EXPIRES_AT = time.time() + expires_in
|
|
1640
|
+
return access_token
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
# Fetches fresh client token
|
|
1644
|
+
def spotify_get_client_token(app_version, device_id, system_id, **device_overrides):
|
|
1645
|
+
global SP_CACHED_CLIENT_TOKEN, SP_CLIENT_TOKEN_EXPIRES_AT
|
|
1646
|
+
|
|
1647
|
+
if SP_CACHED_CLIENT_TOKEN and time.time() < SP_CLIENT_TOKEN_EXPIRES_AT:
|
|
1648
|
+
return SP_CACHED_CLIENT_TOKEN
|
|
1649
|
+
|
|
1650
|
+
body = build_clienttoken_request_protobuf(app_version, device_id, system_id, **device_overrides)
|
|
1651
|
+
|
|
1652
|
+
headers = {
|
|
1653
|
+
"Host": "clienttoken.spotify.com",
|
|
1654
|
+
"Connection": "keep-alive",
|
|
1655
|
+
"Pragma": "no-cache",
|
|
1656
|
+
"Cache-Control": "no-cache, no-store, max-age=0",
|
|
1657
|
+
"Accept": "application/x-protobuf",
|
|
1658
|
+
"Content-Type": "application/x-protobuf",
|
|
1659
|
+
"User-Agent": USER_AGENT,
|
|
1660
|
+
"Origin": "https://clienttoken.spotify.com",
|
|
1661
|
+
"Accept-Language": "en-Latn-GB,en-GB;q=0.9,en;q=0.8",
|
|
1662
|
+
"Sec-Fetch-Site": "same-origin",
|
|
1663
|
+
"Sec-Fetch-Mode": "no-cors",
|
|
1664
|
+
"Sec-Fetch-Dest": "empty",
|
|
1665
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
try:
|
|
1669
|
+
if platform.system() != 'Windows':
|
|
1670
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1671
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1672
|
+
response = req.post(CLIENTTOKEN_URL, headers=headers, data=body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1673
|
+
except (req.RequestException, TimeoutException) as e:
|
|
1674
|
+
raise Exception(f"spotify_get_client_token() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
|
|
1675
|
+
finally:
|
|
1676
|
+
if platform.system() != 'Windows':
|
|
1677
|
+
signal.alarm(0)
|
|
1678
|
+
|
|
1679
|
+
if response.status_code != 200:
|
|
1680
|
+
raise Exception(f"clienttoken request failed - status {response.status_code}\nHeaders: {response.headers}\nBody (raw): {response.content[:120]}...")
|
|
1681
|
+
|
|
1682
|
+
parsed = parse_protobuf_message(response.content)
|
|
1683
|
+
inner = parsed.get(2, {})
|
|
1684
|
+
client_token = deep_flatten(inner.get(1)) if inner.get(1) else None
|
|
1685
|
+
ttl = int(inner.get(3, 0)) or 1209600
|
|
1686
|
+
|
|
1687
|
+
if not client_token:
|
|
1688
|
+
raise Exception("clienttoken response did not contain a token")
|
|
1689
|
+
|
|
1690
|
+
SP_CACHED_CLIENT_TOKEN = client_token
|
|
1691
|
+
SP_CLIENT_TOKEN_EXPIRES_AT = time.time() + ttl
|
|
1692
|
+
|
|
1693
|
+
return client_token
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
# Fetches Spotify access token with automatic client token refresh
|
|
1697
|
+
def spotify_get_access_token_from_client_auto(device_id, system_id, user_uri_id, refresh_token):
|
|
1698
|
+
client_token = None
|
|
1699
|
+
|
|
1700
|
+
if all([
|
|
1701
|
+
CLIENTTOKEN_URL,
|
|
1702
|
+
APP_VERSION,
|
|
1703
|
+
CPU_ARCH is not None and CPU_ARCH > 0,
|
|
1704
|
+
OS_BUILD is not None and OS_BUILD > 0,
|
|
1705
|
+
PLATFORM is not None and PLATFORM > 0,
|
|
1706
|
+
OS_MAJOR is not None and OS_MAJOR > 0,
|
|
1707
|
+
OS_MINOR is not None and OS_MINOR > 0,
|
|
1708
|
+
CLIENT_MODEL is not None and CLIENT_MODEL > 0
|
|
1709
|
+
]):
|
|
1710
|
+
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)
|
|
1711
|
+
|
|
1712
|
+
try:
|
|
1713
|
+
return spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token)
|
|
1714
|
+
except Exception as e:
|
|
1715
|
+
err = str(e).lower()
|
|
1716
|
+
if all([
|
|
1717
|
+
CLIENTTOKEN_URL,
|
|
1718
|
+
APP_VERSION,
|
|
1719
|
+
CPU_ARCH is not None and CPU_ARCH > 0,
|
|
1720
|
+
OS_BUILD is not None and OS_BUILD > 0,
|
|
1721
|
+
PLATFORM is not None and PLATFORM > 0,
|
|
1722
|
+
OS_MAJOR is not None and OS_MAJOR > 0,
|
|
1723
|
+
OS_MINOR is not None and OS_MINOR > 0,
|
|
1724
|
+
CLIENT_MODEL is not None and CLIENT_MODEL > 0
|
|
1725
|
+
]) and ("invalid client token" in err or "expired client token" in err):
|
|
1726
|
+
global SP_CLIENT_TOKEN_EXPIRES_AT
|
|
1727
|
+
SP_CLIENT_TOKEN_EXPIRES_AT = 0
|
|
1728
|
+
|
|
1729
|
+
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)
|
|
1730
|
+
|
|
1731
|
+
return spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token)
|
|
1732
|
+
raise
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
# --------------------------------------------------------
|
|
1736
|
+
|
|
1737
|
+
|
|
1099
1738
|
# Fetches list of Spotify friends
|
|
1100
1739
|
def spotify_get_friends_json(access_token):
|
|
1101
1740
|
url = "https://guc-spclient.spotify.com/presence-view/v1/buddylist"
|
|
1102
|
-
headers = {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1741
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1742
|
+
|
|
1743
|
+
if TOKEN_SOURCE == "cookie":
|
|
1744
|
+
headers.update({
|
|
1745
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1746
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1747
|
+
})
|
|
1748
|
+
elif TOKEN_SOURCE == "client":
|
|
1749
|
+
headers.update({
|
|
1750
|
+
"User-Agent": USER_AGENT
|
|
1751
|
+
})
|
|
1107
1752
|
|
|
1108
1753
|
response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1109
1754
|
if response.status_code == 401:
|
|
@@ -1163,8 +1808,6 @@ def spotify_list_friends(friend_activity):
|
|
|
1163
1808
|
sp_playlist_uri = friend["track"]["context"].get("uri")
|
|
1164
1809
|
sp_track_uri = friend["track"].get("uri")
|
|
1165
1810
|
|
|
1166
|
-
# if index > 0:
|
|
1167
|
-
# print("─" * HORIZONTAL_LINE)
|
|
1168
1811
|
print("─" * HORIZONTAL_LINE)
|
|
1169
1812
|
print(f"Username:\t\t\t{sp_username}")
|
|
1170
1813
|
print(f"User URI ID:\t\t\t{sp_uri}")
|
|
@@ -1226,11 +1869,17 @@ def spotify_get_friend_info(friend_activity, uri):
|
|
|
1226
1869
|
def spotify_get_track_info(access_token, track_uri):
|
|
1227
1870
|
track_id = track_uri.split(':', 2)[2]
|
|
1228
1871
|
url = "https://api.spotify.com/v1/tracks/" + track_id
|
|
1229
|
-
headers = {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1872
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1873
|
+
|
|
1874
|
+
if TOKEN_SOURCE == "cookie":
|
|
1875
|
+
headers.update({
|
|
1876
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1877
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1878
|
+
})
|
|
1879
|
+
elif TOKEN_SOURCE == "client":
|
|
1880
|
+
headers.update({
|
|
1881
|
+
"User-Agent": USER_AGENT
|
|
1882
|
+
})
|
|
1234
1883
|
# add si parameter so link opens in native Spotify app after clicking
|
|
1235
1884
|
si = "?si=1"
|
|
1236
1885
|
|
|
@@ -1254,11 +1903,17 @@ def spotify_get_track_info(access_token, track_uri):
|
|
|
1254
1903
|
def spotify_get_playlist_info(access_token, playlist_uri):
|
|
1255
1904
|
playlist_id = playlist_uri.split(':', 2)[2]
|
|
1256
1905
|
url = f"https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner,followers,external_urls"
|
|
1257
|
-
headers = {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1906
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1907
|
+
|
|
1908
|
+
if TOKEN_SOURCE == "cookie":
|
|
1909
|
+
headers.update({
|
|
1910
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1911
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1912
|
+
})
|
|
1913
|
+
elif TOKEN_SOURCE == "client":
|
|
1914
|
+
headers.update({
|
|
1915
|
+
"User-Agent": USER_AGENT
|
|
1916
|
+
})
|
|
1262
1917
|
# add si parameter so link opens in native Spotify app after clicking
|
|
1263
1918
|
si = "?si=1"
|
|
1264
1919
|
|
|
@@ -1279,15 +1934,21 @@ def spotify_get_playlist_info(access_token, playlist_uri):
|
|
|
1279
1934
|
# Gets basic information about access token owner
|
|
1280
1935
|
def spotify_get_current_user(access_token) -> dict | None:
|
|
1281
1936
|
url = "https://api.spotify.com/v1/me"
|
|
1282
|
-
headers = {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1937
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1938
|
+
|
|
1939
|
+
if TOKEN_SOURCE == "cookie":
|
|
1940
|
+
headers.update({
|
|
1941
|
+
"Client-Id": SP_CACHED_CLIENT_ID,
|
|
1942
|
+
"User-Agent": SP_CACHED_USER_AGENT,
|
|
1943
|
+
})
|
|
1944
|
+
elif TOKEN_SOURCE == "client":
|
|
1945
|
+
headers.update({
|
|
1946
|
+
"User-Agent": USER_AGENT
|
|
1947
|
+
})
|
|
1287
1948
|
|
|
1288
1949
|
if platform.system() != 'Windows':
|
|
1289
1950
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1290
|
-
signal.alarm(
|
|
1951
|
+
signal.alarm(FUNCTION_TIMEOUT + 2)
|
|
1291
1952
|
try:
|
|
1292
1953
|
response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
|
|
1293
1954
|
response.raise_for_status()
|
|
@@ -1447,7 +2108,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1447
2108
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1448
2109
|
signal.alarm(ALARM_TIMEOUT)
|
|
1449
2110
|
try:
|
|
1450
|
-
|
|
2111
|
+
if TOKEN_SOURCE == "client":
|
|
2112
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
2113
|
+
else:
|
|
2114
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
1451
2115
|
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
1452
2116
|
sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
|
|
1453
2117
|
email_sent = False
|
|
@@ -1464,12 +2128,27 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1464
2128
|
if platform.system() != 'Windows':
|
|
1465
2129
|
signal.alarm(0)
|
|
1466
2130
|
|
|
2131
|
+
err = str(e).lower()
|
|
2132
|
+
|
|
1467
2133
|
print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
|
|
1468
2134
|
|
|
1469
|
-
if
|
|
2135
|
+
if TOKEN_SOURCE == 'cookie' and '401' in err:
|
|
1470
2136
|
SP_CACHED_ACCESS_TOKEN = None
|
|
1471
2137
|
|
|
1472
|
-
|
|
2138
|
+
client_errs = ['access token', 'invalid client token', 'expired client token']
|
|
2139
|
+
cookie_errs = ['access token', 'unsuccessful token request']
|
|
2140
|
+
|
|
2141
|
+
if TOKEN_SOURCE == 'client' and any(k in err for k in client_errs):
|
|
2142
|
+
print(f"* Error: client token or refresh token might have expired!")
|
|
2143
|
+
if ERROR_NOTIFICATION and not email_sent:
|
|
2144
|
+
m_subject = f"spotify_monitor: client token or refresh token might have expired! (uri: {user_uri_id})"
|
|
2145
|
+
m_body = f"Client token or refresh token might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
|
|
2146
|
+
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>"
|
|
2147
|
+
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
2148
|
+
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
2149
|
+
email_sent = True
|
|
2150
|
+
|
|
2151
|
+
elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
|
|
1473
2152
|
print(f"* Error: sp_dc might have expired!")
|
|
1474
2153
|
if ERROR_NOTIFICATION and not email_sent:
|
|
1475
2154
|
m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
|
|
@@ -1478,6 +2157,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1478
2157
|
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
1479
2158
|
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
1480
2159
|
email_sent = True
|
|
2160
|
+
|
|
1481
2161
|
print_cur_ts("Timestamp:\t\t\t")
|
|
1482
2162
|
time.sleep(SPOTIFY_ERROR_INTERVAL)
|
|
1483
2163
|
continue
|
|
@@ -1494,7 +2174,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1494
2174
|
|
|
1495
2175
|
user_info = spotify_get_current_user(sp_accessToken)
|
|
1496
2176
|
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')} ]")
|
|
2177
|
+
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
2178
|
|
|
1499
2179
|
sp_track_uri = sp_data["sp_track_uri"]
|
|
1500
2180
|
sp_track_uri_id = sp_data["sp_track_uri_id"]
|
|
@@ -1646,7 +2326,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1646
2326
|
signal.signal(signal.SIGALRM, timeout_handler)
|
|
1647
2327
|
signal.alarm(ALARM_TIMEOUT)
|
|
1648
2328
|
try:
|
|
1649
|
-
|
|
2329
|
+
if TOKEN_SOURCE == "client":
|
|
2330
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
2331
|
+
else:
|
|
2332
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
1650
2333
|
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
1651
2334
|
sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
|
|
1652
2335
|
email_sent = False
|
|
@@ -1663,11 +2346,13 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1663
2346
|
if platform.system() != 'Windows':
|
|
1664
2347
|
signal.alarm(0)
|
|
1665
2348
|
|
|
1666
|
-
|
|
2349
|
+
err = str(e).lower()
|
|
2350
|
+
|
|
2351
|
+
if TOKEN_SOURCE == 'cookie' and '401' in err:
|
|
1667
2352
|
SP_CACHED_ACCESS_TOKEN = None
|
|
1668
2353
|
|
|
1669
2354
|
str_matches = ["500 server", "504 server", "502 server", "503 server"]
|
|
1670
|
-
if any(x in
|
|
2355
|
+
if any(x in err for x in str_matches):
|
|
1671
2356
|
if not error_500_start_ts:
|
|
1672
2357
|
error_500_start_ts = int(time.time())
|
|
1673
2358
|
error_500_counter = 1
|
|
@@ -1675,7 +2360,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1675
2360
|
error_500_counter += 1
|
|
1676
2361
|
|
|
1677
2362
|
str_matches = ["timed out", "timeout", "name resolution", "failed to resolve", "family not supported", "429 client", "aborted"]
|
|
1678
|
-
if any(x in
|
|
2363
|
+
if any(x in err for x in str_matches) or str(e) == '':
|
|
1679
2364
|
if not error_network_issue_start_ts:
|
|
1680
2365
|
error_network_issue_start_ts = int(time.time())
|
|
1681
2366
|
error_network_issue_counter = 1
|
|
@@ -1696,7 +2381,21 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1696
2381
|
|
|
1697
2382
|
elif not error_500_start_ts and not error_network_issue_start_ts:
|
|
1698
2383
|
print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: '{e}'")
|
|
1699
|
-
|
|
2384
|
+
|
|
2385
|
+
client_errs = ['access token', 'invalid client token', 'expired client token']
|
|
2386
|
+
cookie_errs = ['access token', 'unsuccessful token request']
|
|
2387
|
+
|
|
2388
|
+
if TOKEN_SOURCE == 'client' and any(k in err for k in client_errs):
|
|
2389
|
+
print(f"* Error: client token or refresh token might have expired!")
|
|
2390
|
+
if ERROR_NOTIFICATION and not email_sent:
|
|
2391
|
+
m_subject = f"spotify_monitor: client token or refresh token might have expired! (uri: {user_uri_id})"
|
|
2392
|
+
m_body = f"Client token or refresh token might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
|
|
2393
|
+
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>"
|
|
2394
|
+
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
2395
|
+
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
2396
|
+
email_sent = True
|
|
2397
|
+
|
|
2398
|
+
elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
|
|
1700
2399
|
print(f"* Error: sp_dc might have expired!")
|
|
1701
2400
|
if ERROR_NOTIFICATION and not email_sent:
|
|
1702
2401
|
m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
|
|
@@ -1705,6 +2404,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
1705
2404
|
print(f"Sending email notification to {RECEIVER_EMAIL}")
|
|
1706
2405
|
send_email(m_subject, m_body, m_body_html, SMTP_SSL)
|
|
1707
2406
|
email_sent = True
|
|
2407
|
+
|
|
1708
2408
|
print_cur_ts("Timestamp:\t\t\t")
|
|
1709
2409
|
time.sleep(SPOTIFY_ERROR_INTERVAL)
|
|
1710
2410
|
|
|
@@ -2036,7 +2736,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
|
|
|
2036
2736
|
|
|
2037
2737
|
|
|
2038
2738
|
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
|
|
2739
|
+
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
2740
|
|
|
2041
2741
|
if "--generate-config" in sys.argv:
|
|
2042
2742
|
print(CONFIG_BLOCK.strip("\n"))
|
|
@@ -2065,7 +2765,8 @@ def main():
|
|
|
2065
2765
|
"user_id",
|
|
2066
2766
|
nargs="?",
|
|
2067
2767
|
metavar="SPOTIFY_USER_URI_ID",
|
|
2068
|
-
help="Spotify user URI ID"
|
|
2768
|
+
help="Spotify user URI ID",
|
|
2769
|
+
type=str
|
|
2069
2770
|
)
|
|
2070
2771
|
|
|
2071
2772
|
# Version, just to list in help, it is handled earlier
|
|
@@ -2095,15 +2796,40 @@ def main():
|
|
|
2095
2796
|
help="Path to optional dotenv file (auto-search if not set, disable with 'none')",
|
|
2096
2797
|
)
|
|
2097
2798
|
|
|
2098
|
-
#
|
|
2099
|
-
|
|
2100
|
-
|
|
2799
|
+
# Token source
|
|
2800
|
+
parser.add_argument(
|
|
2801
|
+
"--token-source",
|
|
2802
|
+
dest="token_source",
|
|
2803
|
+
choices=["cookie", "client"],
|
|
2804
|
+
help="Method to obtain Spotify access token: 'cookie' (via sp_dc cookie) or 'client' (via desktop client login protobuf)"
|
|
2805
|
+
)
|
|
2806
|
+
|
|
2807
|
+
# Cookie token source credentials (used when token source is set to cookie)
|
|
2808
|
+
api_creds = parser.add_argument_group("Cookie token source credentials")
|
|
2809
|
+
api_creds.add_argument(
|
|
2101
2810
|
"-u", "--spotify-dc-cookie",
|
|
2102
2811
|
dest="spotify_dc_cookie",
|
|
2103
2812
|
metavar="SP_DC_COOKIE",
|
|
2813
|
+
type=str,
|
|
2104
2814
|
help="Spotify sp_dc cookie"
|
|
2105
2815
|
)
|
|
2106
2816
|
|
|
2817
|
+
# Client token source credentials (used when token source is set to client)
|
|
2818
|
+
client_creds = parser.add_argument_group("Client token source credentials")
|
|
2819
|
+
client_creds.add_argument(
|
|
2820
|
+
"-w", "--login-request-body-file",
|
|
2821
|
+
dest="login_request_body_file",
|
|
2822
|
+
metavar="PROTOBUF_FILENAME",
|
|
2823
|
+
help="Read device_id, system_id, user_uri_id and refresh_token from binary Protobuf login file"
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
client_creds.add_argument(
|
|
2827
|
+
"-z", "--clienttoken-request-body-file",
|
|
2828
|
+
dest="clienttoken_request_body_file",
|
|
2829
|
+
metavar="PROTOBUF_FILENAME",
|
|
2830
|
+
help="Read app_version, cpu_arch, os_build, platform, os_major, os_minor and client_model from binary Protobuf client token file"
|
|
2831
|
+
)
|
|
2832
|
+
|
|
2107
2833
|
# Notifications
|
|
2108
2834
|
notify = parser.add_argument_group("Notifications")
|
|
2109
2835
|
notify.add_argument(
|
|
@@ -2146,7 +2872,7 @@ def main():
|
|
|
2146
2872
|
dest="notify_errors",
|
|
2147
2873
|
action="store_false",
|
|
2148
2874
|
default=None,
|
|
2149
|
-
help="Disable
|
|
2875
|
+
help="Disable emails on errors"
|
|
2150
2876
|
)
|
|
2151
2877
|
notify.add_argument(
|
|
2152
2878
|
"--send-test-email",
|
|
@@ -2201,7 +2927,7 @@ def main():
|
|
|
2201
2927
|
dest="track_in_spotify",
|
|
2202
2928
|
action="store_true",
|
|
2203
2929
|
default=None,
|
|
2204
|
-
help="Auto
|
|
2930
|
+
help="Auto-play each listened song in your Spotify client"
|
|
2205
2931
|
)
|
|
2206
2932
|
opts.add_argument(
|
|
2207
2933
|
"-b", "--csv-file",
|
|
@@ -2229,7 +2955,7 @@ def main():
|
|
|
2229
2955
|
dest="disable_logging",
|
|
2230
2956
|
action="store_true",
|
|
2231
2957
|
default=None,
|
|
2232
|
-
help="Disable logging to
|
|
2958
|
+
help="Disable logging to spotify_monitor_<user_uri_id/file_suffix>.log"
|
|
2233
2959
|
)
|
|
2234
2960
|
|
|
2235
2961
|
args = parser.parse_args()
|
|
@@ -2288,6 +3014,19 @@ def main():
|
|
|
2288
3014
|
if val is not None:
|
|
2289
3015
|
globals()[secret] = val
|
|
2290
3016
|
|
|
3017
|
+
if args.token_source:
|
|
3018
|
+
TOKEN_SOURCE = args.token_source
|
|
3019
|
+
|
|
3020
|
+
if not TOKEN_SOURCE:
|
|
3021
|
+
TOKEN_SOURCE = "cookie"
|
|
3022
|
+
|
|
3023
|
+
if TOKEN_SOURCE == "cookie":
|
|
3024
|
+
ALARM_TIMEOUT = int((TOKEN_MAX_RETRIES * TOKEN_RETRY_TIMEOUT) + 5)
|
|
3025
|
+
try:
|
|
3026
|
+
import pyotp
|
|
3027
|
+
except ModuleNotFoundError:
|
|
3028
|
+
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")
|
|
3029
|
+
|
|
2291
3030
|
if not check_internet():
|
|
2292
3031
|
sys.exit(1)
|
|
2293
3032
|
|
|
@@ -2309,21 +3048,112 @@ def main():
|
|
|
2309
3048
|
if args.disappeared_timer:
|
|
2310
3049
|
SPOTIFY_DISAPPEARED_CHECK_INTERVAL = args.disappeared_timer
|
|
2311
3050
|
|
|
2312
|
-
if
|
|
2313
|
-
|
|
3051
|
+
if TOKEN_SOURCE == "client":
|
|
3052
|
+
login_request_body_file_param = False
|
|
3053
|
+
if args.login_request_body_file:
|
|
3054
|
+
LOGIN_REQUEST_BODY_FILE = os.path.expanduser(args.login_request_body_file)
|
|
3055
|
+
login_request_body_file_param = True
|
|
3056
|
+
else:
|
|
3057
|
+
if LOGIN_REQUEST_BODY_FILE:
|
|
3058
|
+
LOGIN_REQUEST_BODY_FILE = os.path.expanduser(LOGIN_REQUEST_BODY_FILE)
|
|
2314
3059
|
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
3060
|
+
if LOGIN_REQUEST_BODY_FILE:
|
|
3061
|
+
if os.path.isfile(LOGIN_REQUEST_BODY_FILE):
|
|
3062
|
+
try:
|
|
3063
|
+
DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_request_body_file(LOGIN_REQUEST_BODY_FILE)
|
|
3064
|
+
except Exception as e:
|
|
3065
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
3066
|
+
sys.exit(1)
|
|
3067
|
+
else:
|
|
3068
|
+
if not args.user_id and not args.list_friends and not args.show_user_info and login_request_body_file_param:
|
|
3069
|
+
print(f"* Login data correctly read from Protobuf file ({LOGIN_REQUEST_BODY_FILE}):")
|
|
3070
|
+
print(" - Device ID:\t\t", DEVICE_ID)
|
|
3071
|
+
print(" - System ID:\t\t", SYSTEM_ID)
|
|
3072
|
+
print(" - User URI ID:\t\t", USER_URI_ID)
|
|
3073
|
+
print(" - Refresh Token:\t", REFRESH_TOKEN, "\n")
|
|
3074
|
+
sys.exit(0)
|
|
3075
|
+
else:
|
|
3076
|
+
print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) does not exist")
|
|
3077
|
+
sys.exit(1)
|
|
3078
|
+
|
|
3079
|
+
if not USER_AGENT:
|
|
3080
|
+
USER_AGENT = get_random_spotify_user_agent()
|
|
3081
|
+
|
|
3082
|
+
if any([
|
|
3083
|
+
not LOGIN_URL,
|
|
3084
|
+
not USER_AGENT,
|
|
3085
|
+
not DEVICE_ID,
|
|
3086
|
+
DEVICE_ID == "your_spotify_app_device_id",
|
|
3087
|
+
not SYSTEM_ID,
|
|
3088
|
+
SYSTEM_ID == "your_spotify_app_system_id",
|
|
3089
|
+
not USER_URI_ID,
|
|
3090
|
+
USER_URI_ID == "your_spotify_user_uri_id",
|
|
3091
|
+
not REFRESH_TOKEN,
|
|
3092
|
+
REFRESH_TOKEN == "your_spotify_app_refresh_token",
|
|
3093
|
+
]):
|
|
3094
|
+
print("* Error: Some login values are empty or incorrect")
|
|
3095
|
+
sys.exit(1)
|
|
3096
|
+
|
|
3097
|
+
clienttoken_request_body_file_param = False
|
|
3098
|
+
if args.clienttoken_request_body_file:
|
|
3099
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = os.path.expanduser(args.clienttoken_request_body_file)
|
|
3100
|
+
clienttoken_request_body_file_param = True
|
|
3101
|
+
else:
|
|
3102
|
+
if CLIENTTOKEN_REQUEST_BODY_FILE:
|
|
3103
|
+
CLIENTTOKEN_REQUEST_BODY_FILE = os.path.expanduser(CLIENTTOKEN_REQUEST_BODY_FILE)
|
|
3104
|
+
|
|
3105
|
+
if CLIENTTOKEN_REQUEST_BODY_FILE:
|
|
3106
|
+
if os.path.isfile(CLIENTTOKEN_REQUEST_BODY_FILE):
|
|
3107
|
+
try:
|
|
3108
|
+
|
|
3109
|
+
(APP_VERSION, _, _, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR, CLIENT_MODEL) = parse_clienttoken_request_body_file(CLIENTTOKEN_REQUEST_BODY_FILE)
|
|
3110
|
+
except Exception as e:
|
|
3111
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) cannot be processed: {e}")
|
|
3112
|
+
sys.exit(1)
|
|
3113
|
+
else:
|
|
3114
|
+
if not args.user_id and not args.list_friends and not args.show_user_info and clienttoken_request_body_file_param:
|
|
3115
|
+
print(f"* Client token data correctly read from Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}):")
|
|
3116
|
+
print(" - App version:\t\t", APP_VERSION)
|
|
3117
|
+
print(" - CPU arch:\t\t", CPU_ARCH)
|
|
3118
|
+
print(" - OS build:\t\t", OS_BUILD)
|
|
3119
|
+
print(" - Platform:\t\t", PLATFORM)
|
|
3120
|
+
print(" - OS major:\t\t", OS_MAJOR)
|
|
3121
|
+
print(" - OS minor:\t\t", OS_MINOR)
|
|
3122
|
+
print(" - Client model:\t", CLIENT_MODEL)
|
|
3123
|
+
sys.exit(0)
|
|
3124
|
+
else:
|
|
3125
|
+
print(f"* Error: Protobuf file ({CLIENTTOKEN_REQUEST_BODY_FILE}) does not exist")
|
|
3126
|
+
sys.exit(1)
|
|
3127
|
+
|
|
3128
|
+
app_version_default = "1.2.62.580.g7e3d9a4f"
|
|
3129
|
+
if USER_AGENT and not APP_VERSION:
|
|
3130
|
+
try:
|
|
3131
|
+
APP_VERSION = ua_to_app_version(USER_AGENT)
|
|
3132
|
+
except Exception as e:
|
|
3133
|
+
print(f"Warning: wrong USER_AGENT defined, reverting to the default one: {e}")
|
|
3134
|
+
APP_VERSION = app_version_default
|
|
3135
|
+
else:
|
|
3136
|
+
APP_VERSION = app_version_default
|
|
3137
|
+
|
|
3138
|
+
else:
|
|
3139
|
+
if args.spotify_dc_cookie:
|
|
3140
|
+
SP_DC_COOKIE = args.spotify_dc_cookie
|
|
3141
|
+
|
|
3142
|
+
if not SP_DC_COOKIE or SP_DC_COOKIE == "your_sp_dc_cookie_value":
|
|
3143
|
+
print("* Error: SP_DC_COOKIE (-u / --spotify_dc_cookie) value is empty or incorrect")
|
|
3144
|
+
sys.exit(1)
|
|
2318
3145
|
|
|
2319
3146
|
if args.show_user_info:
|
|
2320
3147
|
print("* Getting basic information about access token owner ...\n")
|
|
2321
3148
|
try:
|
|
2322
|
-
|
|
2323
|
-
|
|
3149
|
+
if TOKEN_SOURCE == "client":
|
|
3150
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
3151
|
+
else:
|
|
3152
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
3153
|
+
user_info = spotify_get_current_user(sp_accessToken)
|
|
2324
3154
|
|
|
2325
3155
|
if user_info:
|
|
2326
|
-
print(f"Token belongs to:\n")
|
|
3156
|
+
print(f"Token fetched via {TOKEN_SOURCE} belongs to:\n")
|
|
2327
3157
|
|
|
2328
3158
|
print(f"Username:\t\t{user_info.get('display_name', '')}")
|
|
2329
3159
|
print(f"User URI ID:\t\t{user_info.get('uri', '').split('spotify:user:', 1)[1]}")
|
|
@@ -2343,11 +3173,14 @@ def main():
|
|
|
2343
3173
|
if args.list_friends:
|
|
2344
3174
|
print("* Listing Spotify friends ...\n")
|
|
2345
3175
|
try:
|
|
2346
|
-
|
|
2347
|
-
|
|
3176
|
+
if TOKEN_SOURCE == "client":
|
|
3177
|
+
sp_accessToken = spotify_get_access_token_from_client_auto(DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN)
|
|
3178
|
+
else:
|
|
3179
|
+
sp_accessToken = spotify_get_access_token_from_sp_dc(SP_DC_COOKIE)
|
|
3180
|
+
user_info = spotify_get_current_user(sp_accessToken)
|
|
2348
3181
|
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(
|
|
3182
|
+
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')} ]")
|
|
3183
|
+
sp_friends = spotify_get_friends_json(sp_accessToken)
|
|
2351
3184
|
spotify_list_friends(sp_friends)
|
|
2352
3185
|
print("─" * HORIZONTAL_LINE)
|
|
2353
3186
|
except Exception as e:
|
|
@@ -2453,6 +3286,7 @@ def main():
|
|
|
2453
3286
|
|
|
2454
3287
|
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
3288
|
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}]")
|
|
3289
|
+
print(f"* Token source:\t\t\t{TOKEN_SOURCE}")
|
|
2456
3290
|
print(f"* Track listened songs:\t\t{TRACK_SONGS}")
|
|
2457
3291
|
print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
|
|
2458
3292
|
print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
|