spotify-monitor 2.0rc1__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.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.0
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.0"
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 spotify_get_access_token() function
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
- # Variables for caching functionality of the Spotify access token to avoid unnecessary refreshing
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
- SP_TOKEN_EXPIRES_AT = 0
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
- # Default value for alarm signal handler timeout; in seconds
289
- ALARM_TIMEOUT = int((TOKEN_MAX_RETRIES * TOKEN_RETRY_TIMEOUT) + 5)
290
- ALARM_RETRY = 10
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 variables)")
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 .env
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
- print(f"* Signal {sig_name} received")
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
- # Prepares Apple & Genius search URLs for specified track
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 spotify_get_access_token(sp_dc: str):
1056
- global SP_CACHED_ACCESS_TOKEN, SP_TOKEN_EXPIRES_AT, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT
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 < SP_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT):
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
- SP_TOKEN_EXPIRES_AT = token_data["expires_at"]
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
- "Authorization": f"Bearer {access_token}",
1104
- "Client-Id": SP_CACHED_CLIENT_ID,
1105
- "User-Agent": SP_CACHED_USER_AGENT,
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:
@@ -1123,7 +1767,10 @@ def spotify_convert_uri_to_url(uri):
1123
1767
  si = "?si=1"
1124
1768
  # si=""
1125
1769
 
1770
+ uri = uri or ''
1126
1771
  url = ""
1772
+ if not isinstance(uri, str):
1773
+ return url
1127
1774
  if "spotify:user:" in uri:
1128
1775
  s_id = uri.split(':', 2)[2]
1129
1776
  url = f"https://open.spotify.com/user/{s_id}{si}"
@@ -1223,11 +1870,13 @@ def spotify_get_friend_info(friend_activity, uri):
1223
1870
  def spotify_get_track_info(access_token, track_uri):
1224
1871
  track_id = track_uri.split(':', 2)[2]
1225
1872
  url = "https://api.spotify.com/v1/tracks/" + track_id
1226
- headers = {
1227
- "Authorization": f"Bearer {access_token}",
1228
- "Client-Id": SP_CACHED_CLIENT_ID,
1229
- "User-Agent": SP_CACHED_USER_AGENT,
1230
- }
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
+ })
1231
1880
  # add si parameter so link opens in native Spotify app after clicking
1232
1881
  si = "?si=1"
1233
1882
 
@@ -1251,11 +1900,13 @@ def spotify_get_track_info(access_token, track_uri):
1251
1900
  def spotify_get_playlist_info(access_token, playlist_uri):
1252
1901
  playlist_id = playlist_uri.split(':', 2)[2]
1253
1902
  url = f"https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner,followers,external_urls"
1254
- headers = {
1255
- "Authorization": f"Bearer {access_token}",
1256
- "Client-Id": SP_CACHED_CLIENT_ID,
1257
- "User-Agent": SP_CACHED_USER_AGENT,
1258
- }
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
+ })
1259
1910
  # add si parameter so link opens in native Spotify app after clicking
1260
1911
  si = "?si=1"
1261
1912
 
@@ -1276,15 +1927,17 @@ def spotify_get_playlist_info(access_token, playlist_uri):
1276
1927
  # Gets basic information about access token owner
1277
1928
  def spotify_get_current_user(access_token) -> dict | None:
1278
1929
  url = "https://api.spotify.com/v1/me"
1279
- headers = {
1280
- "Authorization": f"Bearer {access_token}",
1281
- "Client-Id": SP_CACHED_CLIENT_ID,
1282
- "User-Agent": SP_CACHED_USER_AGENT,
1283
- }
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
+ })
1284
1937
 
1285
1938
  if platform.system() != 'Windows':
1286
1939
  signal.signal(signal.SIGALRM, timeout_handler)
1287
- signal.alarm(ALARM_TIMEOUT + 2)
1940
+ signal.alarm(FUNCTION_TIMEOUT + 2)
1288
1941
  try:
1289
1942
  response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1290
1943
  response.raise_for_status()
@@ -1444,7 +2097,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1444
2097
  signal.signal(signal.SIGALRM, timeout_handler)
1445
2098
  signal.alarm(ALARM_TIMEOUT)
1446
2099
  try:
1447
- sp_accessToken = spotify_get_access_token(SP_DC_COOKIE)
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)
1448
2104
  sp_friends = spotify_get_friends_json(sp_accessToken)
1449
2105
  sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
1450
2106
  email_sent = False
@@ -1461,12 +2117,27 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1461
2117
  if platform.system() != 'Windows':
1462
2118
  signal.alarm(0)
1463
2119
 
2120
+ err = str(e).lower()
2121
+
1464
2122
  print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
1465
2123
 
1466
- if "401" in str(e):
2124
+ if TOKEN_SOURCE == 'cookie' and '401' in err:
1467
2125
  SP_CACHED_ACCESS_TOKEN = None
1468
2126
 
1469
- if ('access token' in str(e)) or ('Unsuccessful token request' in str(e)):
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):
1470
2141
  print(f"* Error: sp_dc might have expired!")
1471
2142
  if ERROR_NOTIFICATION and not email_sent:
1472
2143
  m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
@@ -1475,6 +2146,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1475
2146
  print(f"Sending email notification to {RECEIVER_EMAIL}")
1476
2147
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1477
2148
  email_sent = True
2149
+
1478
2150
  print_cur_ts("Timestamp:\t\t\t")
1479
2151
  time.sleep(SPOTIFY_ERROR_INTERVAL)
1480
2152
  continue
@@ -1491,7 +2163,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1491
2163
 
1492
2164
  user_info = spotify_get_current_user(sp_accessToken)
1493
2165
  if user_info:
1494
- 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')} ]")
1495
2167
 
1496
2168
  sp_track_uri = sp_data["sp_track_uri"]
1497
2169
  sp_track_uri_id = sp_data["sp_track_uri_id"]
@@ -1643,7 +2315,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1643
2315
  signal.signal(signal.SIGALRM, timeout_handler)
1644
2316
  signal.alarm(ALARM_TIMEOUT)
1645
2317
  try:
1646
- sp_accessToken = spotify_get_access_token(SP_DC_COOKIE)
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)
1647
2322
  sp_friends = spotify_get_friends_json(sp_accessToken)
1648
2323
  sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
1649
2324
  email_sent = False
@@ -1660,11 +2335,13 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1660
2335
  if platform.system() != 'Windows':
1661
2336
  signal.alarm(0)
1662
2337
 
1663
- if "401" in str(e):
2338
+ err = str(e).lower()
2339
+
2340
+ if TOKEN_SOURCE == 'cookie' and '401' in err:
1664
2341
  SP_CACHED_ACCESS_TOKEN = None
1665
2342
 
1666
2343
  str_matches = ["500 server", "504 server", "502 server", "503 server"]
1667
- if any(x in str(e).lower() for x in str_matches):
2344
+ if any(x in err for x in str_matches):
1668
2345
  if not error_500_start_ts:
1669
2346
  error_500_start_ts = int(time.time())
1670
2347
  error_500_counter = 1
@@ -1672,7 +2349,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1672
2349
  error_500_counter += 1
1673
2350
 
1674
2351
  str_matches = ["timed out", "timeout", "name resolution", "failed to resolve", "family not supported", "429 client", "aborted"]
1675
- if any(x in str(e).lower() for x in str_matches) or str(e) == '':
2352
+ if any(x in err for x in str_matches) or str(e) == '':
1676
2353
  if not error_network_issue_start_ts:
1677
2354
  error_network_issue_start_ts = int(time.time())
1678
2355
  error_network_issue_counter = 1
@@ -1693,7 +2370,21 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1693
2370
 
1694
2371
  elif not error_500_start_ts and not error_network_issue_start_ts:
1695
2372
  print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: '{e}'")
1696
- if ('access token' in str(e)) or ('Unsuccessful token request' in str(e)):
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):
1697
2388
  print(f"* Error: sp_dc might have expired!")
1698
2389
  if ERROR_NOTIFICATION and not email_sent:
1699
2390
  m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
@@ -1702,6 +2393,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1702
2393
  print(f"Sending email notification to {RECEIVER_EMAIL}")
1703
2394
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1704
2395
  email_sent = True
2396
+
1705
2397
  print_cur_ts("Timestamp:\t\t\t")
1706
2398
  time.sleep(SPOTIFY_ERROR_INTERVAL)
1707
2399
 
@@ -2033,7 +2725,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2033
2725
 
2034
2726
 
2035
2727
  def main():
2036
- 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
2037
2729
 
2038
2730
  if "--generate-config" in sys.argv:
2039
2731
  print(CONFIG_BLOCK.strip("\n"))
@@ -2062,7 +2754,8 @@ def main():
2062
2754
  "user_id",
2063
2755
  nargs="?",
2064
2756
  metavar="SPOTIFY_USER_URI_ID",
2065
- help="Spotify user URI ID"
2757
+ help="Spotify user URI ID",
2758
+ type=str
2066
2759
  )
2067
2760
 
2068
2761
  # Version, just to list in help, it is handled earlier
@@ -2092,15 +2785,40 @@ def main():
2092
2785
  help="Path to optional dotenv file (auto-search if not set, disable with 'none')",
2093
2786
  )
2094
2787
 
2095
- # API credentials
2096
- creds = parser.add_argument_group("API credentials")
2097
- creds.add_argument(
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(
2098
2799
  "-u", "--spotify-dc-cookie",
2099
2800
  dest="spotify_dc_cookie",
2100
2801
  metavar="SP_DC_COOKIE",
2802
+ type=str,
2101
2803
  help="Spotify sp_dc cookie"
2102
2804
  )
2103
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
+
2104
2822
  # Notifications
2105
2823
  notify = parser.add_argument_group("Notifications")
2106
2824
  notify.add_argument(
@@ -2143,7 +2861,7 @@ def main():
2143
2861
  dest="notify_errors",
2144
2862
  action="store_false",
2145
2863
  default=None,
2146
- help="Disable email on errors (e.g. expired sp_dc)"
2864
+ help="Disable emails on errors"
2147
2865
  )
2148
2866
  notify.add_argument(
2149
2867
  "--send-test-email",
@@ -2226,7 +2944,7 @@ def main():
2226
2944
  dest="disable_logging",
2227
2945
  action="store_true",
2228
2946
  default=None,
2229
- help="Disable logging to file spotify_monitor_<user_uri_id/file_suffix>.log"
2947
+ help="Disable logging to spotify_monitor_<user_uri_id/file_suffix>.log"
2230
2948
  )
2231
2949
 
2232
2950
  args = parser.parse_args()
@@ -2285,6 +3003,19 @@ def main():
2285
3003
  if val is not None:
2286
3004
  globals()[secret] = val
2287
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
+
2288
3019
  if not check_internet():
2289
3020
  sys.exit(1)
2290
3021
 
@@ -2306,21 +3037,112 @@ def main():
2306
3037
  if args.disappeared_timer:
2307
3038
  SPOTIFY_DISAPPEARED_CHECK_INTERVAL = args.disappeared_timer
2308
3039
 
2309
- if args.spotify_dc_cookie:
2310
- SP_DC_COOKIE = args.spotify_dc_cookie
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)
2311
3048
 
2312
- if not SP_DC_COOKIE or SP_DC_COOKIE == "your_sp_dc_cookie_value":
2313
- print("* Error: SP_DC_COOKIE (-u / --spotify-dc-cookie) value is empty or incorrect")
2314
- sys.exit(1)
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)
2315
3134
 
2316
3135
  if args.show_user_info:
2317
3136
  print("* Getting basic information about access token owner ...\n")
2318
3137
  try:
2319
- accessToken = spotify_get_access_token(SP_DC_COOKIE)
2320
- user_info = spotify_get_current_user(accessToken)
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)
2321
3143
 
2322
3144
  if user_info:
2323
- print(f"Token belongs to:\n")
3145
+ print(f"Token fetched via {TOKEN_SOURCE} belongs to:\n")
2324
3146
 
2325
3147
  print(f"Username:\t\t{user_info.get('display_name', '')}")
2326
3148
  print(f"User URI ID:\t\t{user_info.get('uri', '').split('spotify:user:', 1)[1]}")
@@ -2340,11 +3162,14 @@ def main():
2340
3162
  if args.list_friends:
2341
3163
  print("* Listing Spotify friends ...\n")
2342
3164
  try:
2343
- accessToken = spotify_get_access_token(SP_DC_COOKIE)
2344
- user_info = spotify_get_current_user(accessToken)
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)
2345
3170
  if user_info:
2346
- print(f"Token belongs to:\t\t{user_info.get('display_name', '')}\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
2347
- sp_friends = spotify_get_friends_json(accessToken)
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)
2348
3173
  spotify_list_friends(sp_friends)
2349
3174
  print("─" * HORIZONTAL_LINE)
2350
3175
  except Exception as e:
@@ -2450,6 +3275,7 @@ def main():
2450
3275
 
2451
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)}]")
2452
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}")
2453
3279
  print(f"* Track listened songs:\t\t{TRACK_SONGS}")
2454
3280
  print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
2455
3281
  print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))