spotify-monitor 2.1.1__py3-none-any.whl → 2.2__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.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.1.1
4
+ v2.2
5
5
 
6
6
  Tool implementing real-time tracking of Spotify friends music activity:
7
7
  https://github.com/misiektoja/spotify_monitor/
@@ -11,11 +11,11 @@ Python pip3 requirements:
11
11
  requests
12
12
  python-dateutil
13
13
  urllib3
14
- pyotp (needed when the token source is set to cookie)
14
+ pyotp (optional, needed when the token source is set to cookie)
15
15
  python-dotenv (optional)
16
16
  """
17
17
 
18
- VERSION = "2.1.1"
18
+ VERSION = "2.2"
19
19
 
20
20
  # ---------------------------
21
21
  # CONFIGURATION SECTION START
@@ -24,27 +24,25 @@ VERSION = "2.1.1"
24
24
  CONFIG_BLOCK = """
25
25
  # Select the method used to obtain the Spotify access token
26
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)
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
29
  TOKEN_SOURCE = "cookie"
30
30
 
31
- # ------------------------------------------------
31
+ # ---------------------------------------------------------------------
32
32
 
33
33
  # The section below is used when the token source is set to 'cookie'
34
34
  # (to configure the alternative 'client' method, see the section at the end of this config block)
35
35
  #
36
- # Log in to Spotify web client (https://open.spotify.com/) and retrieve your sp_dc cookie
37
- # Use your web browser's dev console or "Cookie-Editor" by cgagnier to extract it easily: https://cookie-editor.com/
38
- #
39
- # Provide the SP_DC_COOKIE secret using one of the following methods:
36
+ # - Log in to Spotify web client (https://open.spotify.com/) and retrieve your sp_dc cookie
37
+ # (use your web browser's dev console or "Cookie-Editor" by cgagnier to extract it easily: https://cookie-editor.com/)
38
+ # - Provide the SP_DC_COOKIE secret using one of the following methods:
40
39
  # - Pass it at runtime with -u / --spotify-dc-cookie
41
40
  # - Set it as an environment variable (e.g. export SP_DC_COOKIE=...)
42
41
  # - Add it to ".env" file (SP_DC_COOKIE=...) for persistent use
43
- # Fallback:
44
- # - Hard-code it in the code or config file
42
+ # - Fallback: hard-code it in the code or config file
45
43
  SP_DC_COOKIE = "your_sp_dc_cookie_value"
46
44
 
47
- # ------------------------------------------------
45
+ # ---------------------------------------------------------------------
48
46
 
49
47
  # SMTP settings for sending email notifications
50
48
  # If left as-is, no notifications will be sent
@@ -151,6 +149,19 @@ SP_USER_GOT_OFFLINE_TRACK_ID = ""
151
149
  # Set to 0 to keep playing indefinitely until manually paused
152
150
  SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE = 5 # 5 seconds
153
151
 
152
+ # Optional: specify user agent manually
153
+ #
154
+ # When the token source is 'cookie' - set it to web browser user agent, some examples:
155
+ # Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0
156
+ # Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0
157
+ #
158
+ # When the token source is 'client' - set it to Spotify desktop client user agent, some examples:
159
+ # Spotify/126200580 Win32_x86_64/0 (PC desktop)
160
+ # Spotify/126400408 OSX_ARM64/OS X 15.5.0 [arm 2]
161
+ #
162
+ # Leave empty to auto-generate it randomly for specific token source
163
+ USER_AGENT = ""
164
+
154
165
  # How often to print a "liveness check" message to the output; in seconds
155
166
  # Set to 0 to disable
156
167
  LIVENESS_CHECK_INTERVAL = 43200 # 12 hours
@@ -219,44 +230,64 @@ TOKEN_MAX_RETRIES = 10
219
230
  # Used only when the token source is set to 'cookie'
220
231
  TOKEN_RETRY_TIMEOUT = 0.5 # 0.5 second
221
232
 
222
- # ------------------------------------------------
233
+ # ---------------------------------------------------------------------
234
+
223
235
  # The section below is used when the token source is set to 'client'
224
236
  #
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
237
+ # - Run an intercepting proxy of your choice (like Proxyman)
238
+ # - Launch the Spotify desktop client and look for requests to: https://login{n}.spotify.com/v3/login
239
+ # (the 'login' part is suffixed with one or more digits)
240
+ # - Export the login request body (a binary Protobuf payload) to a file
231
241
  # (e.g. in Proxyman: right click the request -> Export -> Request Body -> Save File -> <login-request-body-file>)
232
242
  #
233
- # Can also be set using the -w flag
243
+ # To automatically extract DEVICE_ID, SYSTEM_ID, USER_URI_ID and REFRESH_TOKEN from the exported binary login
244
+ # request Protobuf file:
245
+ #
246
+ # - Run the tool with the -w flag to indicate an exported file or specify its file name below
234
247
  LOGIN_REQUEST_BODY_FILE = ""
235
248
 
236
- # Alternatively, set the configuration options below manually
249
+ # Alternatively, you can manually set the DEVICE_ID, SYSTEM_ID, USER_URI_ID and REFRESH_TOKEN options
250
+ # (however, using the automated method described above is recommended)
251
+ #
252
+ # These values can be extracted using one of the following methods:
253
+ #
254
+ # - Run spotify_profile_monitor with the -w flag without specifying SPOTIFY_USER_URI_ID - it will decode the file and
255
+ # print the values to stdout, example:
256
+ # spotify_profile_monitor --token-source client -w <path-to-login-request-body-file>
237
257
  #
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
258
+ # - Use the protoc tool (part of protobuf pip package):
259
+ # pip install protobuf
260
+ # protoc --decode_raw < <path-to-login-request-body-file>
244
261
  #
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>
262
+ # - Use the built-in Protobuf decoder in your intercepting proxy (if supported)
247
263
  #
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:
264
+ # The Protobuf structure is as follows:
265
+ #
266
+ # {
267
+ # 1: {
268
+ # 1: "DEVICE_ID",
269
+ # 2: "SYSTEM_ID"
270
+ # },
271
+ # 100: {
272
+ # 1: "USER_URI_ID",
273
+ # 2: "REFRESH_TOKEN"
274
+ # }
275
+ # }
276
+ #
277
+ # Provide the extracted values below (DEVICE_ID, SYSTEM_ID, USER_URI_ID). The REFRESH_TOKEN secret can be
278
+ # supplied using one of the following methods:
250
279
  # - Set it as an environment variable (e.g. export REFRESH_TOKEN=...)
251
280
  # - Add it to ".env" file (REFRESH_TOKEN=...) for persistent use
252
- # Fallback:
253
- # - Hard-code it in the code or config file
281
+ # - Fallback: hard-code it in the code or config file
254
282
  DEVICE_ID = "your_spotify_app_device_id"
255
283
  SYSTEM_ID = "your_spotify_app_system_id"
256
284
  USER_URI_ID = "your_spotify_user_uri_id"
257
285
  REFRESH_TOKEN = "your_spotify_app_refresh_token"
258
286
 
259
- # Default values below are typically fine - only modify if necessary
287
+ # ----------------------------------------------
288
+ # Advanced options for 'client' token source
289
+ # Modifying the values below is NOT recommended!
290
+ # ----------------------------------------------
260
291
 
261
292
  # Spotify login URL
262
293
  LOGIN_URL = "https://login5.spotify.com/v3/login"
@@ -264,20 +295,58 @@ LOGIN_URL = "https://login5.spotify.com/v3/login"
264
295
  # Spotify client token URL
265
296
  CLIENTTOKEN_URL = "https://clienttoken.spotify.com/v1/clienttoken"
266
297
 
267
- # Optional: specify user agent manually, some examples:
298
+ # Platform-specific values for token generation so the Spotify client token requests match your exact Spotify desktop
299
+ # client build (arch, OS build, app version etc.)
268
300
  #
269
- # Spotify/126200580 Win32_x86_64/0 (PC desktop)
270
- # Spotify/126400408 OSX_ARM64/OS X 15.5.0 [arm 2]
301
+ # - Run an intercepting proxy of your choice (like Proxyman)
302
+ # - Launch the Spotify desktop client and look for requests to: https://clienttoken.spotify.com/v1/clienttoken
303
+ # (these requests are sent every time client token expires, usually every 2 weeks)
304
+ # - Export the client token request body (a binary Protobuf payload) to a file
305
+ # (e.g. in Proxyman: right click the request -> Export -> Request Body -> Save File -> <clienttoken-request-body-file>)
271
306
  #
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 = ""
307
+ # To automatically extract APP_VERSION, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR and CLIENT_MODEL from the
308
+ # exported binary client token request Protobuf file:
309
+ #
310
+ # - Run the tool with the hidden -z flag to indicate an exported file or specify its file name below
311
+ CLIENTTOKEN_REQUEST_BODY_FILE = ""
278
312
 
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)
313
+ # Alternatively, you can manually set the APP_VERSION, CPU_ARCH, OS_BUILD, PLATFORM, OS_MAJOR, OS_MINOR and
314
+ # CLIENT_MODEL options
315
+ #
316
+ # These values can be extracted using one of the following methods:
317
+ #
318
+ # - run spotify_profile_monitor with the hidden -z flag without specifying SPOTIFY_USER_URI_ID - it will decode the file
319
+ # and print the values to stdout, example:
320
+ # spotify_profile_monitor --token-source client -z <path-to-clienttoken-request-body-file>
321
+ #
322
+ # - use the protoc tool (part of protobuf pip package):
323
+ # pip install protobuf
324
+ # protoc --decode_raw < <path-to-clienttoken-request-body-file>
325
+ #
326
+ # - use the built-in Protobuf decoder in your intercepting proxy (if supported)
327
+ #
328
+ # The Protobuf structure is as follows:
329
+ #
330
+ # 1: 1
331
+ # 2 {
332
+ # 1: "APP_VERSION"
333
+ # 2: "DEVICE_ID"
334
+ # 3 {
335
+ # 1 {
336
+ # 4 {
337
+ # 1: "CPU_ARCH"
338
+ # 3: "OS_BUILD"
339
+ # 4: "PLATFORM"
340
+ # 5: "OS_MAJOR"
341
+ # 6: "OS_MINOR"
342
+ # 8: "CLIENT_MODEL"
343
+ # }
344
+ # }
345
+ # 2: "SYSTEM_ID"
346
+ # }
347
+ # }
348
+ #
349
+ # Provide the extracted values below (except for DEVICE_ID and SYSTEM_ID as it was already provided via -w)
281
350
  CPU_ARCH = 10
282
351
  OS_BUILD = 19045
283
352
  PLATFORM = 2
@@ -285,19 +354,11 @@ OS_MAJOR = 9
285
354
  OS_MINOR = 9
286
355
  CLIENT_MODEL = 34404
287
356
 
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 = ""
357
+ # App version (e.g. '1.2.62.580.g7e3d9a4f')
358
+ # Leave empty to auto-generate from USER_AGENT
359
+ APP_VERSION = ""
299
360
 
300
- # ------------------------------------------------
361
+ # ---------------------------------------------------------------------
301
362
  """
302
363
 
303
364
  # -------------------------
@@ -311,7 +372,6 @@ SP_DC_COOKIE = ""
311
372
  LOGIN_REQUEST_BODY_FILE = ""
312
373
  CLIENTTOKEN_REQUEST_BODY_FILE = ""
313
374
  LOGIN_URL = ""
314
- USER_AGENT = ""
315
375
  DEVICE_ID = ""
316
376
  SYSTEM_ID = ""
317
377
  USER_URI_ID = ""
@@ -349,6 +409,7 @@ SONG_ON_LOOP_VALUE = 0
349
409
  SKIPPED_SONG_THRESHOLD = 0
350
410
  SP_USER_GOT_OFFLINE_TRACK_ID = ""
351
411
  SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE = 0
412
+ USER_AGENT = ""
352
413
  LIVENESS_CHECK_INTERVAL = 0
353
414
  CHECK_INTERNET_URL = ""
354
415
  CHECK_INTERNET_TIMEOUT = 0
@@ -393,13 +454,9 @@ SP_CACHED_ACCESS_TOKEN = None
393
454
  SP_CACHED_REFRESH_TOKEN = None
394
455
  SP_ACCESS_TOKEN_EXPIRES_AT = 0
395
456
  SP_CACHED_CLIENT_ID = ""
396
- SP_CACHED_USER_AGENT = ""
397
457
 
398
458
  # URL of the Spotify Web Player endpoint to get access token
399
- TOKEN_URL = "https://open.spotify.com/get_access_token"
400
-
401
- # URL of the endpoint to get server time needed to create TOTP object
402
- SERVER_TIME_URL = "https://open.spotify.com/server-time"
459
+ TOKEN_URL = "https://open.spotify.com/api/token"
403
460
 
404
461
  # Variables for caching functionality of the Spotify client token to avoid unnecessary refreshing
405
462
  SP_CACHED_CLIENT_TOKEN = None
@@ -451,6 +508,7 @@ import shutil
451
508
  from pathlib import Path
452
509
  import secrets
453
510
  from typing import Optional
511
+ from email.utils import parsedate_to_datetime
454
512
 
455
513
  import urllib3
456
514
  if not VERIFY_SSL:
@@ -468,7 +526,8 @@ retry = Retry(
468
526
  backoff_factor=1,
469
527
  status_forcelist=[429, 500, 502, 503, 504],
470
528
  allowed_methods=["GET", "HEAD", "OPTIONS"],
471
- raise_on_status=False
529
+ raise_on_status=False,
530
+ respect_retry_after_header=True
472
531
  )
473
532
 
474
533
  adapter = HTTPAdapter(max_retries=retry, pool_connections=100, pool_maxsize=100)
@@ -512,7 +571,7 @@ def signal_handler(sig, frame):
512
571
  # Checks internet connectivity
513
572
  def check_internet(url=CHECK_INTERNET_URL, timeout=CHECK_INTERNET_TIMEOUT, verify=VERIFY_SSL):
514
573
  try:
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)
574
+ _ = req.get(url, headers={'User-Agent': USER_AGENT}, timeout=timeout, verify=verify)
516
575
  return True
517
576
  except req.RequestException as e:
518
577
  print(f"* No connectivity, please check your network:\n\n{e}")
@@ -930,7 +989,7 @@ def reload_secrets_signal_handler(sig, frame):
930
989
  if LOGIN_REQUEST_BODY_FILE:
931
990
  if os.path.isfile(LOGIN_REQUEST_BODY_FILE):
932
991
  try:
933
- DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_request_body_file(LOGIN_REQUEST_BODY_FILE)
992
+ DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_login_request_body_file(LOGIN_REQUEST_BODY_FILE)
934
993
  except Exception as e:
935
994
  print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) cannot be processed: {e}")
936
995
  else:
@@ -1091,30 +1150,10 @@ def get_random_user_agent() -> str:
1091
1150
  return ""
1092
1151
 
1093
1152
 
1094
- # Removes spaces from a hex string and converts it into a corresponding bytes object
1095
- def hex_to_bytes(data: str) -> bytes:
1096
- data = data.replace(" ", "")
1097
- return bytes.fromhex(data)
1098
-
1099
-
1100
- # Creates a TOTP object using a secret derived from transformed cipher bytes
1101
- def generate_totp(ua: str):
1102
- import pyotp
1103
-
1104
- secret_cipher_bytes = [
1105
- 12, 56, 76, 33, 88, 44, 88, 33,
1106
- 78, 78, 11, 66, 22, 22, 55, 69, 54,
1107
- ]
1108
-
1109
- transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
1110
- joined = "".join(str(num) for num in transformed)
1111
- utf8_bytes = joined.encode("utf-8")
1112
- hex_str = "".join(format(b, 'x') for b in utf8_bytes)
1113
- secret_bytes = hex_to_bytes(hex_str)
1114
- secret = base64.b32encode(secret_bytes).decode().rstrip('=')
1153
+ # Returns Spotify edge-server Unix time
1154
+ def fetch_server_time(session: req.Session, ua: str) -> int:
1115
1155
 
1116
1156
  headers = {
1117
- "Host": "open.spotify.com",
1118
1157
  "User-Agent": ua,
1119
1158
  "Accept": "*/*",
1120
1159
  }
@@ -1123,28 +1162,38 @@ def generate_totp(ua: str):
1123
1162
  if platform.system() != 'Windows':
1124
1163
  signal.signal(signal.SIGALRM, timeout_handler)
1125
1164
  signal.alarm(FUNCTION_TIMEOUT + 2)
1126
- resp = req.get(SERVER_TIME_URL, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1127
- except (req.RequestException, TimeoutException) as e:
1128
- raise Exception(f"generate_totp() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1165
+ response = session.head("https://open.spotify.com/", headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1166
+ response.raise_for_status()
1167
+ except TimeoutException as e:
1168
+ raise Exception(f"fetch_server_time() head network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1169
+ except Exception as e:
1170
+ raise Exception(f"fetch_server_time() head network request error: {e}")
1129
1171
  finally:
1130
1172
  if platform.system() != 'Windows':
1131
1173
  signal.alarm(0)
1132
1174
 
1133
- resp.raise_for_status()
1175
+ return int(parsedate_to_datetime(response.headers["Date"]).timestamp())
1134
1176
 
1135
- json_data = resp.json()
1136
- server_time = json_data.get("serverTime")
1137
1177
 
1138
- if server_time is None:
1139
- raise Exception("Failed to get server time")
1178
+ # Creates a TOTP object using a secret derived from transformed cipher bytes
1179
+ def generate_totp():
1180
+ import pyotp
1181
+
1182
+ secret_cipher_bytes = [
1183
+ 12, 56, 76, 33, 88, 44, 88, 33,
1184
+ 78, 78, 11, 66, 22, 22, 55, 69, 54,
1185
+ ]
1140
1186
 
1141
- totp_obj = pyotp.TOTP(secret, digits=6, interval=30)
1187
+ transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
1188
+ joined = "".join(str(num) for num in transformed)
1189
+ hex_str = joined.encode().hex()
1190
+ secret = base64.b32encode(bytes.fromhex(hex_str)).decode().rstrip("=")
1142
1191
 
1143
- return totp_obj, server_time
1192
+ return pyotp.TOTP(secret, digits=6, interval=30)
1144
1193
 
1145
1194
 
1146
- # Retrieves a new Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
1147
- def refresh_token(sp_dc: str) -> dict:
1195
+ # Refreshes the Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
1196
+ def refresh_access_token_from_sp_dc(sp_dc: str) -> dict:
1148
1197
  transport = True
1149
1198
  init = True
1150
1199
  session = req.Session()
@@ -1152,8 +1201,8 @@ def refresh_token(sp_dc: str) -> dict:
1152
1201
  data: dict = {}
1153
1202
  token = ""
1154
1203
 
1155
- ua = get_random_user_agent()
1156
- totp_obj, server_time = generate_totp(ua)
1204
+ server_time = fetch_server_time(session, USER_AGENT)
1205
+ totp_obj = generate_totp()
1157
1206
  client_time = int(time_ns() / 1000 / 1000)
1158
1207
  otp_value = totp_obj.at(server_time)
1159
1208
 
@@ -1165,14 +1214,20 @@ def refresh_token(sp_dc: str) -> dict:
1165
1214
  "totpVer": 5,
1166
1215
  "sTime": server_time,
1167
1216
  "cTime": client_time,
1217
+ "buildDate": time.strftime("%Y-%m-%d", time.gmtime(server_time)),
1218
+ "buildVer": f"web-player_{time.strftime('%Y-%m-%d', time.gmtime(server_time))}_{server_time * 1000}_{secrets.token_hex(4)}",
1168
1219
  }
1169
1220
 
1170
1221
  headers = {
1171
- "User-Agent": ua,
1222
+ "User-Agent": USER_AGENT,
1172
1223
  "Accept": "application/json",
1224
+ "Referer": "https://open.spotify.com/",
1225
+ "App-Platform": "WebPlayer",
1173
1226
  "Cookie": f"sp_dc={sp_dc}",
1174
1227
  }
1175
1228
 
1229
+ last_err = ""
1230
+
1176
1231
  try:
1177
1232
  if platform.system() != "Windows":
1178
1233
  signal.signal(signal.SIGALRM, timeout_handler)
@@ -1183,13 +1238,14 @@ def refresh_token(sp_dc: str) -> dict:
1183
1238
  data = response.json()
1184
1239
  token = data.get("accessToken", "")
1185
1240
 
1186
- except (req.RequestException, TimeoutException, req.HTTPError, ValueError):
1241
+ except (req.RequestException, TimeoutException, req.HTTPError, ValueError) as e:
1187
1242
  transport = False
1243
+ last_err = str(e)
1188
1244
  finally:
1189
1245
  if platform.system() != "Windows":
1190
1246
  signal.alarm(0)
1191
1247
 
1192
- if not transport or (transport and not check_token_validity(token, data.get("clientId", ""), ua)):
1248
+ if not transport or (transport and not check_token_validity(token, data.get("clientId", ""), USER_AGENT)):
1193
1249
  params["reason"] = "init"
1194
1250
 
1195
1251
  try:
@@ -1202,58 +1258,52 @@ def refresh_token(sp_dc: str) -> dict:
1202
1258
  data = response.json()
1203
1259
  token = data.get("accessToken", "")
1204
1260
 
1205
- except (req.RequestException, TimeoutException, req.HTTPError, ValueError):
1261
+ except (req.RequestException, TimeoutException, req.HTTPError, ValueError) as e:
1206
1262
  init = False
1263
+ last_err = str(e)
1207
1264
  finally:
1208
1265
  if platform.system() != "Windows":
1209
1266
  signal.alarm(0)
1210
1267
 
1211
1268
  if not init or not data or "accessToken" not in data:
1212
- raise Exception("refresh_token(): Unsuccessful token request")
1269
+ raise Exception(f"refresh_access_token_from_sp_dc(): Unsuccessful token request{': ' + last_err if last_err else ''}")
1213
1270
 
1214
1271
  return {
1215
1272
  "access_token": token,
1216
1273
  "expires_at": data["accessTokenExpirationTimestampMs"] // 1000,
1217
1274
  "client_id": data.get("clientId", ""),
1218
- "user_agent": ua,
1219
1275
  "length": len(token)
1220
1276
  }
1221
1277
 
1222
1278
 
1223
1279
  # Fetches Spotify access token based on provided SP_DC value
1224
1280
  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
1281
+ global SP_CACHED_ACCESS_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT, SP_CACHED_CLIENT_ID
1226
1282
 
1227
1283
  now = time.time()
1228
1284
 
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):
1285
+ if SP_CACHED_ACCESS_TOKEN and now < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, USER_AGENT):
1230
1286
  return SP_CACHED_ACCESS_TOKEN
1231
1287
 
1232
1288
  max_retries = TOKEN_MAX_RETRIES
1233
1289
  retry = 0
1234
1290
 
1235
1291
  while retry < max_retries:
1236
- token_data = refresh_token(sp_dc)
1292
+ token_data = refresh_access_token_from_sp_dc(sp_dc)
1237
1293
  token = token_data["access_token"]
1238
1294
  client_id = token_data.get("client_id", "")
1239
- user_agent = token_data.get("user_agent", get_random_user_agent())
1240
1295
  length = token_data["length"]
1241
1296
 
1242
1297
  SP_CACHED_ACCESS_TOKEN = token
1243
1298
  SP_ACCESS_TOKEN_EXPIRES_AT = token_data["expires_at"]
1244
1299
  SP_CACHED_CLIENT_ID = client_id
1245
- SP_CACHED_USER_AGENT = user_agent
1246
1300
 
1247
- if SP_CACHED_ACCESS_TOKEN is None or not check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT):
1301
+ if SP_CACHED_ACCESS_TOKEN is None or not check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, USER_AGENT):
1248
1302
  retry += 1
1249
1303
  time.sleep(TOKEN_RETRY_TIMEOUT)
1250
1304
  else:
1251
- # print("* Token is valid")
1252
1305
  break
1253
1306
 
1254
- # print("Spotify Access Token:", SP_CACHED_ACCESS_TOKEN)
1255
- # print("Token expires at:", time.ctime(SP_TOKEN_EXPIRES_AT))
1256
-
1257
1307
  if retry == max_retries:
1258
1308
  if SP_CACHED_ACCESS_TOKEN is not None:
1259
1309
  print(f"* Token appears to be still invalid after {max_retries} attempts, returning token anyway")
@@ -1326,13 +1376,15 @@ def encode_nested_field(tag, nested_bytes):
1326
1376
  # Builds the Spotify Protobuf login request body
1327
1377
  def build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token):
1328
1378
  """
1329
- 1 {
1330
- 1: "device_id"
1331
- 2: "system_id"
1332
- }
1333
- 100 {
1334
- 1: "user_uri_id"
1335
- 2: "refresh_token"
1379
+ {
1380
+ 1: {
1381
+ 1: "device_id",
1382
+ 2: "system_id"
1383
+ },
1384
+ 100: {
1385
+ 1: "user_uri_id",
1386
+ 2: "refresh_token"
1387
+ }
1336
1388
  }
1337
1389
  """
1338
1390
  device_info_msg = encode_string_field(1, device_id) + encode_string_field(2, system_id)
@@ -1397,7 +1449,7 @@ def parse_protobuf_message(data):
1397
1449
 
1398
1450
  # Parses the Protobuf-encoded login request body file (as dumped for example by Proxyman) and returns a tuple:
1399
1451
  # (device_id, system_id, user_uri_id, refresh_token)
1400
- def parse_request_body_file(file_path):
1452
+ def parse_login_request_body_file(file_path):
1401
1453
  """
1402
1454
  {
1403
1455
  1: {
@@ -1474,6 +1526,26 @@ def ensure_dict(value):
1474
1526
  # Parses the Protobuf-encoded client token request body file (as dumped for example by Proxyman) and returns a tuple:
1475
1527
  # (app_version, device_id, system_id, cpu_arch, os_build, platform, os_major, os_minor, client_model)
1476
1528
  def parse_clienttoken_request_body_file(file_path):
1529
+ """
1530
+ 1: 1 (const)
1531
+ 2: {
1532
+ 1: "app_version"
1533
+ 2: "device_id"
1534
+ 3: {
1535
+ 1: {
1536
+ 4: {
1537
+ 1: "cpu_arch"
1538
+ 3: "os_build"
1539
+ 4: "platform"
1540
+ 5: "os_major"
1541
+ 6: "os_minor"
1542
+ 8: "client_model"
1543
+ }
1544
+ }
1545
+ 2: "system_id"
1546
+ }
1547
+ }
1548
+ """
1477
1549
 
1478
1550
  with open(file_path, "rb") as f:
1479
1551
  data = f.read()
@@ -1534,13 +1606,20 @@ def build_clienttoken_request_protobuf(app_version, device_id, system_id, cpu_ar
1534
1606
  """
1535
1607
  1: 1 (const)
1536
1608
  2: {
1537
- 1: app_version
1538
- 2: device_id
1609
+ 1: "app_version"
1610
+ 2: "device_id"
1539
1611
  3: {
1540
1612
  1: {
1541
- 4: device_details
1613
+ 4: {
1614
+ 1: "cpu_arch"
1615
+ 3: "os_build"
1616
+ 4: "platform"
1617
+ 5: "os_major"
1618
+ 6: "os_minor"
1619
+ 8: "client_model"
1620
+ }
1542
1621
  }
1543
- 2: system_id
1622
+ 2: "system_id"
1544
1623
  }
1545
1624
  }
1546
1625
  """
@@ -1598,8 +1677,10 @@ def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refr
1598
1677
  signal.signal(signal.SIGALRM, timeout_handler)
1599
1678
  signal.alarm(FUNCTION_TIMEOUT + 2)
1600
1679
  response = req.post(LOGIN_URL, headers=headers, data=protobuf_body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1601
- except (req.RequestException, TimeoutException) as e:
1680
+ except TimeoutException as e:
1602
1681
  raise Exception(f"spotify_get_access_token_from_client() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1682
+ except Exception as e:
1683
+ raise Exception(f"spotify_get_access_token_from_client() network request error: {e}")
1603
1684
  finally:
1604
1685
  if platform.system() != 'Windows':
1605
1686
  signal.alarm(0)
@@ -1610,6 +1691,22 @@ def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refr
1610
1691
  elif response.headers.get("client-token-error") == "EXPIRED_CLIENTTOKEN":
1611
1692
  raise Exception(f"Request failed with status {response.status_code}: expired client token")
1612
1693
 
1694
+ try:
1695
+ error_json = response.json()
1696
+ except ValueError:
1697
+ error_json = {}
1698
+
1699
+ if error_json.get("error") == "invalid_grant":
1700
+ desc = error_json.get("error_description", "")
1701
+ if "refresh token" in desc.lower() and "revoked" in desc.lower():
1702
+ raise Exception(f"Request failed with status {response.status_code}: refresh token has been revoked")
1703
+ elif "refresh token" in desc.lower() and "expired" in desc.lower():
1704
+ raise Exception(f"Request failed with status {response.status_code}: refresh token has expired")
1705
+ elif "invalid refresh token" in desc.lower():
1706
+ raise Exception(f"Request failed with status {response.status_code}: refresh token is invalid")
1707
+ else:
1708
+ raise Exception(f"Request failed with status {response.status_code}: invalid grant during refresh ({desc})")
1709
+
1613
1710
  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
1711
 
1615
1712
  parsed = parse_protobuf_message(response.content)
@@ -1670,8 +1767,10 @@ def spotify_get_client_token(app_version, device_id, system_id, **device_overrid
1670
1767
  signal.signal(signal.SIGALRM, timeout_handler)
1671
1768
  signal.alarm(FUNCTION_TIMEOUT + 2)
1672
1769
  response = req.post(CLIENTTOKEN_URL, headers=headers, data=body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1673
- except (req.RequestException, TimeoutException) as e:
1770
+ except TimeoutException as e:
1674
1771
  raise Exception(f"spotify_get_client_token() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1772
+ except Exception as e:
1773
+ raise Exception(f"spotify_get_client_token() network request error: {e}")
1675
1774
  finally:
1676
1775
  if platform.system() != 'Windows':
1677
1776
  signal.alarm(0)
@@ -1738,16 +1837,14 @@ def spotify_get_access_token_from_client_auto(device_id, system_id, user_uri_id,
1738
1837
  # Fetches list of Spotify friends
1739
1838
  def spotify_get_friends_json(access_token):
1740
1839
  url = "https://guc-spclient.spotify.com/presence-view/v1/buddylist"
1741
- headers = {"Authorization": f"Bearer {access_token}"}
1840
+ headers = {
1841
+ "Authorization": f"Bearer {access_token}",
1842
+ "User-Agent": USER_AGENT
1843
+ }
1742
1844
 
1743
1845
  if TOKEN_SOURCE == "cookie":
1744
1846
  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
1847
+ "Client-Id": SP_CACHED_CLIENT_ID
1751
1848
  })
1752
1849
 
1753
1850
  response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
@@ -1836,8 +1933,8 @@ def spotify_list_friends(friend_activity):
1836
1933
 
1837
1934
  apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
1838
1935
 
1839
- print(f"Apple search URL:\t\t{apple_search_url}")
1840
- print(f"YouTube Music search URL:\t{youtube_music_search_url}")
1936
+ print(f"Apple Music URL:\t\t{apple_search_url}")
1937
+ print(f"YouTube Music URL:\t\t{youtube_music_search_url}")
1841
1938
  print(f"Genius lyrics URL:\t\t{genius_search_url}")
1842
1939
 
1843
1940
  print(f"\nLast activity:\t\t\t{get_date_from_ts(float(str(sp_ts)[0:-3]))} ({calculate_timespan(int(time.time()), datetime.fromtimestamp(float(str(sp_ts)[0:-3])))} ago)")
@@ -1869,16 +1966,14 @@ def spotify_get_friend_info(friend_activity, uri):
1869
1966
  def spotify_get_track_info(access_token, track_uri):
1870
1967
  track_id = track_uri.split(':', 2)[2]
1871
1968
  url = "https://api.spotify.com/v1/tracks/" + track_id
1872
- headers = {"Authorization": f"Bearer {access_token}"}
1969
+ headers = {
1970
+ "Authorization": f"Bearer {access_token}",
1971
+ "User-Agent": USER_AGENT
1972
+ }
1873
1973
 
1874
1974
  if TOKEN_SOURCE == "cookie":
1875
1975
  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
1976
+ "Client-Id": SP_CACHED_CLIENT_ID
1882
1977
  })
1883
1978
  # add si parameter so link opens in native Spotify app after clicking
1884
1979
  si = "?si=1"
@@ -1903,16 +1998,14 @@ def spotify_get_track_info(access_token, track_uri):
1903
1998
  def spotify_get_playlist_info(access_token, playlist_uri):
1904
1999
  playlist_id = playlist_uri.split(':', 2)[2]
1905
2000
  url = f"https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner,followers,external_urls"
1906
- headers = {"Authorization": f"Bearer {access_token}"}
2001
+ headers = {
2002
+ "Authorization": f"Bearer {access_token}",
2003
+ "User-Agent": USER_AGENT
2004
+ }
1907
2005
 
1908
2006
  if TOKEN_SOURCE == "cookie":
1909
2007
  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
2008
+ "Client-Id": SP_CACHED_CLIENT_ID
1916
2009
  })
1917
2010
  # add si parameter so link opens in native Spotify app after clicking
1918
2011
  si = "?si=1"
@@ -1934,16 +2027,14 @@ def spotify_get_playlist_info(access_token, playlist_uri):
1934
2027
  # Gets basic information about access token owner
1935
2028
  def spotify_get_current_user(access_token) -> dict | None:
1936
2029
  url = "https://api.spotify.com/v1/me"
1937
- headers = {"Authorization": f"Bearer {access_token}"}
2030
+ headers = {
2031
+ "Authorization": f"Bearer {access_token}",
2032
+ "User-Agent": USER_AGENT
2033
+ }
1938
2034
 
1939
2035
  if TOKEN_SOURCE == "cookie":
1940
2036
  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
2037
+ "Client-Id": SP_CACHED_CLIENT_ID
1947
2038
  })
1948
2039
 
1949
2040
  if platform.system() != 'Windows':
@@ -1972,6 +2063,29 @@ def spotify_get_current_user(access_token) -> dict | None:
1972
2063
  signal.alarm(0)
1973
2064
 
1974
2065
 
2066
+ # Checks if a Spotify user URI ID has been deleted
2067
+ def is_user_removed(access_token, user_uri_id):
2068
+ url = f"https://api.spotify.com/v1/users/{user_uri_id}"
2069
+
2070
+ headers = {
2071
+ "Authorization": f"Bearer {access_token}",
2072
+ "User-Agent": USER_AGENT
2073
+ }
2074
+
2075
+ if TOKEN_SOURCE == "cookie":
2076
+ headers.update({
2077
+ "Client-Id": SP_CACHED_CLIENT_ID
2078
+ })
2079
+
2080
+ try:
2081
+ response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
2082
+ if response.status_code == 404:
2083
+ return True
2084
+ return False
2085
+ except Exception:
2086
+ return False
2087
+
2088
+
1975
2089
  def spotify_macos_play_song(sp_track_uri_id, method=SPOTIFY_MACOS_PLAYING_METHOD):
1976
2090
  if method == "apple-script": # apple-script
1977
2091
  script = f'tell app "Spotify" to play track "spotify:track:{sp_track_uri_id}"'
@@ -2064,7 +2178,7 @@ def resolve_executable(path):
2064
2178
  raise FileNotFoundError(f"Could not find executable '{path}'")
2065
2179
 
2066
2180
 
2067
- # Main function that monitors activity of the specified Spotify friend's user URI ID
2181
+ # Monitors music activity of the specified Spotify friend's user URI ID
2068
2182
  def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2069
2183
  global SP_CACHED_ACCESS_TOKEN
2070
2184
  sp_active_ts_start = 0
@@ -2084,6 +2198,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2084
2198
  error_500_start_ts = 0
2085
2199
  error_network_issue_counter = 0
2086
2200
  error_network_issue_start_ts = 0
2201
+ sp_accessToken = ""
2087
2202
 
2088
2203
  try:
2089
2204
  if csv_file_name:
@@ -2135,25 +2250,25 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2135
2250
  if TOKEN_SOURCE == 'cookie' and '401' in err:
2136
2251
  SP_CACHED_ACCESS_TOKEN = None
2137
2252
 
2138
- client_errs = ['access token', 'invalid client token', 'expired client token']
2139
- cookie_errs = ['access token', 'unsuccessful token request']
2253
+ client_errs = ['access token', 'invalid client token', 'expired client token', 'refresh token has been revoked', 'refresh token has expired', 'refresh token is invalid', 'invalid grant during refresh']
2254
+ cookie_errs = ['access token', 'unauthorized']
2140
2255
 
2141
2256
  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!")
2257
+ print(f"* Error: client or refresh token may be invalid or expired!")
2143
2258
  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>"
2259
+ m_subject = f"spotify_monitor: client or refresh token may be invalid or expired! (uri: {user_uri_id})"
2260
+ m_body = f"Client or refresh token may be invalid or expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2261
+ m_body_html = f"<html><head></head><body>Client or refresh token may be invalid or expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2147
2262
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2148
2263
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2149
2264
  email_sent = True
2150
2265
 
2151
2266
  elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
2152
- print(f"* Error: sp_dc might have expired!")
2267
+ print(f"* Error: sp_dc may be invalid or expired!")
2153
2268
  if ERROR_NOTIFICATION and not email_sent:
2154
- m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
2155
- m_body = f"sp_dc might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2156
- m_body_html = f"<html><head></head><body>sp_dc might have expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2269
+ m_subject = f"spotify_monitor: sp_dc may be invalid or expired! (uri: {user_uri_id})"
2270
+ m_body = f"sp_dc may be invalid or expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2271
+ m_body_html = f"<html><head></head><body>sp_dc may be invalid or expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2157
2272
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2158
2273
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2159
2274
  email_sent = True
@@ -2262,8 +2377,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2262
2377
 
2263
2378
  apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
2264
2379
 
2265
- print(f"Apple search URL:\t\t{apple_search_url}")
2266
- print(f"YouTube Music search URL:\t{youtube_music_search_url}")
2380
+ print(f"Apple Music URL:\t\t{apple_search_url}")
2381
+ print(f"YouTube Music URL:\t\t{youtube_music_search_url}")
2267
2382
  print(f"Genius lyrics URL:\t\t{genius_search_url}")
2268
2383
 
2269
2384
  if not is_playlist:
@@ -2290,8 +2405,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2290
2405
 
2291
2406
  if ACTIVE_NOTIFICATION:
2292
2407
  m_subject = f"Spotify user {sp_username} is active: '{sp_artist} - {sp_track}'"
2293
- m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple search URL: {apple_search_url}\nYouTube Music search URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2294
- m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple search URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music search URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2408
+ m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple Music URL: {apple_search_url}\nYouTube Music URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2409
+ m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple Music URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2295
2410
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2296
2411
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2297
2412
 
@@ -2316,7 +2431,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2316
2431
 
2317
2432
  email_sent = False
2318
2433
 
2319
- # Main loop
2434
+ # Primary loop
2320
2435
  while True:
2321
2436
 
2322
2437
  while True:
@@ -2382,25 +2497,25 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2382
2497
  elif not error_500_start_ts and not error_network_issue_start_ts:
2383
2498
  print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: '{e}'")
2384
2499
 
2385
- client_errs = ['access token', 'invalid client token', 'expired client token']
2386
- cookie_errs = ['access token', 'unsuccessful token request']
2500
+ client_errs = ['access token', 'invalid client token', 'expired client token', 'refresh token has been revoked', 'refresh token has expired', 'refresh token is invalid', 'invalid grant during refresh']
2501
+ cookie_errs = ['access token', 'unauthorized']
2387
2502
 
2388
2503
  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!")
2504
+ print(f"* Error: client or refresh token may be invalid or expired!")
2390
2505
  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>"
2506
+ m_subject = f"spotify_monitor: client or refresh token may be invalid or expired! (uri: {user_uri_id})"
2507
+ m_body = f"Client or refresh token may be invalid or expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2508
+ m_body_html = f"<html><head></head><body>Client or refresh token may be invalid or expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2394
2509
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2395
2510
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2396
2511
  email_sent = True
2397
2512
 
2398
2513
  elif TOKEN_SOURCE == 'cookie' and any(k in err for k in cookie_errs):
2399
- print(f"* Error: sp_dc might have expired!")
2514
+ print(f"* Error: sp_dc may be invalid or expired!")
2400
2515
  if ERROR_NOTIFICATION and not email_sent:
2401
- m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
2402
- m_body = f"sp_dc might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2403
- m_body_html = f"<html><head></head><body>sp_dc might have expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2516
+ m_subject = f"spotify_monitor: sp_dc may be invalid or expired! (uri: {user_uri_id})"
2517
+ m_body = f"sp_dc may be invalid or expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2518
+ m_body_html = f"<html><head></head><body>sp_dc may be invalid or expired!<br>{escape(str(e))}{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2404
2519
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2405
2520
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2406
2521
  email_sent = True
@@ -2409,15 +2524,24 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2409
2524
  time.sleep(SPOTIFY_ERROR_INTERVAL)
2410
2525
 
2411
2526
  if sp_found is False:
2412
- # User disappeared from the Spotify's friend list
2527
+ # User has disappeared from the Spotify's friend list or account has been removed
2413
2528
  if user_not_found is False:
2414
- print(f"Spotify user {user_uri_id} ({sp_username}) disappeared - make sure your friend is followed and has activity sharing enabled. Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals")
2415
- if ERROR_NOTIFICATION:
2416
- m_subject = f"Spotify user {user_uri_id} ({sp_username}) disappeared!"
2417
- m_body = f"Spotify user {user_uri_id} ({sp_username}) disappeared - make sure your friend is followed and has activity sharing enabled\nRetrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2418
- m_body_html = f"<html><head></head><body>Spotify user {user_uri_id} ({sp_username}) disappeared - make sure your friend is followed and has activity sharing enabled<br>Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2419
- print(f"Sending email notification to {RECEIVER_EMAIL}")
2420
- send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2529
+ if is_user_removed(sp_accessToken, user_uri_id):
2530
+ print(f"Spotify user '{user_uri_id}' ({sp_username}) was probably removed! Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals")
2531
+ if ERROR_NOTIFICATION:
2532
+ m_subject = f"Spotify user {user_uri_id} ({sp_username}) was probably removed!"
2533
+ m_body = f"Spotify user {user_uri_id} ({sp_username}) was probably removed\nRetrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2534
+ m_body_html = f"<html><head></head><body>Spotify user {user_uri_id} ({sp_username}) was probably removed<br>Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2535
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2536
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2537
+ else:
2538
+ print(f"Spotify user '{user_uri_id}' ({sp_username}) has disappeared - make sure your friend is followed and has activity sharing enabled. Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals")
2539
+ if ERROR_NOTIFICATION:
2540
+ m_subject = f"Spotify user {user_uri_id} ({sp_username}) has disappeared!"
2541
+ m_body = f"Spotify user {user_uri_id} ({sp_username}) has disappeared - make sure your friend is followed and has activity sharing enabled\nRetrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2542
+ m_body_html = f"<html><head></head><body>Spotify user {user_uri_id} ({sp_username}) has disappeared - make sure your friend is followed and has activity sharing enabled<br>Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2543
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2544
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2421
2545
  print_cur_ts("Timestamp:\t\t\t")
2422
2546
  user_not_found = True
2423
2547
  time.sleep(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)
@@ -2425,11 +2549,11 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2425
2549
  else:
2426
2550
  # User reappeared in the Spotify's friend list
2427
2551
  if user_not_found is True:
2428
- print(f"Spotify user {user_uri_id} ({sp_username}) appeared again!")
2552
+ print(f"Spotify user {user_uri_id} ({sp_username}) has reappeared!")
2429
2553
  if ERROR_NOTIFICATION:
2430
- m_subject = f"Spotify user {user_uri_id} ({sp_username}) appeared!"
2431
- m_body = f"Spotify user {user_uri_id} ({sp_username}) appeared again!{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2432
- m_body_html = f"<html><head></head><body>Spotify user {user_uri_id} ({sp_username}) appeared again!{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2554
+ m_subject = f"Spotify user {user_uri_id} ({sp_username}) has reappeared!"
2555
+ m_body = f"Spotify user {user_uri_id} ({sp_username}) has reappeared!{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
2556
+ m_body_html = f"<html><head></head><body>Spotify user {user_uri_id} ({sp_username}) has reappeared!{get_cur_ts('<br><br>Timestamp: ')}</body></html>"
2433
2557
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2434
2558
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2435
2559
  print_cur_ts("Timestamp:\t\t\t")
@@ -2561,8 +2685,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2561
2685
 
2562
2686
  apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
2563
2687
 
2564
- print(f"Apple search URL:\t\t{apple_search_url}")
2565
- print(f"YouTube Music search URL:\t{youtube_music_search_url}")
2688
+ print(f"Apple Music URL:\t\t{apple_search_url}")
2689
+ print(f"YouTube Music URL:\t\t{youtube_music_search_url}")
2566
2690
  print(f"Genius lyrics URL:\t\t{genius_search_url}")
2567
2691
 
2568
2692
  if not is_playlist:
@@ -2581,6 +2705,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2581
2705
  listened_songs = 1
2582
2706
  skipped_songs = 0
2583
2707
  looped_songs = 0
2708
+ song_on_loop = 1
2584
2709
 
2585
2710
  print(f"\n*** Friend got ACTIVE after being offline for {calculate_timespan(int(sp_active_ts_start), int(sp_active_ts_stop))} ({get_date_from_ts(sp_active_ts_stop)})")
2586
2711
  m_subject = f"Spotify user {sp_username} is active: '{sp_artist} - {sp_track}' (after {calculate_timespan(int(sp_active_ts_start), int(sp_active_ts_stop), show_seconds=False)} - {get_short_date_from_ts(sp_active_ts_stop)})"
@@ -2597,8 +2722,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2597
2722
  sp_active_ts_start = sp_active_ts_start_old
2598
2723
  sp_active_ts_stop = 0
2599
2724
 
2600
- m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple search URL: {apple_search_url}\nYouTube Music search URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}{friend_active_m_body}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2601
- m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple search URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music search URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a>{friend_active_m_body_html}<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2725
+ m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple Music URL: {apple_search_url}\nYouTube Music URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}{friend_active_m_body}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2726
+ m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple Music URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a>{friend_active_m_body_html}<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2602
2727
 
2603
2728
  if ACTIVE_NOTIFICATION:
2604
2729
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2612,16 +2737,16 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2612
2737
 
2613
2738
  if (TRACK_NOTIFICATION and on_the_list and not email_sent) or (SONG_NOTIFICATION and not email_sent):
2614
2739
  m_subject = f"Spotify user {sp_username}: '{sp_artist} - {sp_track}'"
2615
- m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple search URL: {apple_search_url}\nYouTube Music search URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2616
- m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple search URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music search URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2740
+ m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple Music URL: {apple_search_url}\nYouTube Music URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2741
+ m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple Music URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2617
2742
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2618
2743
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2619
2744
  email_sent = True
2620
2745
 
2621
2746
  if song_on_loop == SONG_ON_LOOP_VALUE and SONG_ON_LOOP_NOTIFICATION:
2622
2747
  m_subject = f"Spotify user {sp_username} plays song on loop: '{sp_artist} - {sp_track}'"
2623
- m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple search URL: {apple_search_url}\nYouTube Music search URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nUser plays song on LOOP ({song_on_loop} times)\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2624
- m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple search URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music search URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>User plays song on LOOP (<b>{song_on_loop}</b> times)<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2748
+ m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple Music URL: {apple_search_url}\nYouTube Music URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nUser plays song on LOOP ({song_on_loop} times)\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2749
+ m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple Music URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>User plays song on LOOP (<b>{song_on_loop}</b> times)<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2625
2750
  if not email_sent:
2626
2751
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2627
2752
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
@@ -2688,8 +2813,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2688
2813
  spotify_linux_play_pause("pause")
2689
2814
  if INACTIVE_NOTIFICATION:
2690
2815
  m_subject = f"Spotify user {sp_username} is inactive: '{sp_artist} - {sp_track}' (after {calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start), show_seconds=False)}: {get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True)})"
2691
- m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple search URL: {apple_search_url}\nYouTube Music search URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nFriend got inactive after listening to music for {calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}\nFriend played music from {get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True, between_sep=' to ')}{listened_songs_mbody}\n\nLast activity: {get_date_from_ts(sp_active_ts_stop)}\nInactivity timer: {display_time(SPOTIFY_INACTIVITY_CHECK)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2692
- m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple search URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music search URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Friend got inactive after listening to music for <b>{calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}</b><br>Friend played music from <b>{get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True, between_sep='</b> to <b>')}</b>{listened_songs_mbody_html}<br><br>Last activity: <b>{get_date_from_ts(sp_active_ts_stop)}</b><br>Inactivity timer: {display_time(SPOTIFY_INACTIVITY_CHECK)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2816
+ m_body = f"Last played: {sp_artist} - {sp_track}\nDuration: {display_time(sp_track_duration)}{played_for_m_body}{playlist_m_body}\nAlbum: {sp_album}{context_m_body}\n\nApple Music URL: {apple_search_url}\nYouTube Music URL:{youtube_music_search_url}\nGenius lyrics URL: {genius_search_url}\n\nFriend got inactive after listening to music for {calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}\nFriend played music from {get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True, between_sep=' to ')}{listened_songs_mbody}\n\nLast activity: {get_date_from_ts(sp_active_ts_stop)}\nInactivity timer: {display_time(SPOTIFY_INACTIVITY_CHECK)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2817
+ m_body_html = f"<html><head></head><body>Last played: <b><a href=\"{sp_artist_url}\">{escape(sp_artist)}</a> - <a href=\"{sp_track_url}\">{escape(sp_track)}</a></b><br>Duration: {display_time(sp_track_duration)}{played_for_m_body_html}{playlist_m_body_html}<br>Album: <a href=\"{sp_album_url}\">{escape(sp_album)}</a>{context_m_body_html}<br><br>Apple Music URL: <a href=\"{apple_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>YouTube Music URL: <a href=\"{youtube_music_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br>Genius lyrics URL: <a href=\"{genius_search_url}\">{escape(sp_artist)} - {escape(sp_track)}</a><br><br>Friend got inactive after listening to music for <b>{calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}</b><br>Friend played music from <b>{get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True, between_sep='</b> to <b>')}</b>{listened_songs_mbody_html}<br><br>Last activity: <b>{get_date_from_ts(sp_active_ts_stop)}</b><br>Inactivity timer: {display_time(SPOTIFY_INACTIVITY_CHECK)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2693
2818
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2694
2819
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2695
2820
  email_sent = True
@@ -2701,6 +2826,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2701
2826
  listened_songs = 0
2702
2827
  looped_songs = 0
2703
2828
  skipped_songs = 0
2829
+ song_on_loop = 0
2704
2830
  print_cur_ts("\nTimestamp:\t\t\t")
2705
2831
 
2706
2832
  if LIVENESS_CHECK_COUNTER and alive_counter >= LIVENESS_CHECK_COUNTER:
@@ -2728,7 +2854,10 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2728
2854
  # User is not found in the Spotify's friend list just after starting the tool
2729
2855
  else:
2730
2856
  if user_not_found is False:
2731
- print(f"User {user_uri_id} not found - make sure your friend is followed and has activity sharing enabled. Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)}.")
2857
+ if is_user_removed(sp_accessToken, user_uri_id):
2858
+ print(f"User '{user_uri_id}' does not exist! Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals")
2859
+ else:
2860
+ print(f"User '{user_uri_id}' not found - make sure your friend is followed and has activity sharing enabled. Retrying in {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)} intervals")
2732
2861
  print_cur_ts("Timestamp:\t\t\t")
2733
2862
  user_not_found = True
2734
2863
  time.sleep(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)
@@ -2736,7 +2865,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2736
2865
 
2737
2866
 
2738
2867
  def main():
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
2868
+ 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, USER_AGENT
2740
2869
 
2741
2870
  if "--generate-config" in sys.argv:
2742
2871
  print(CONFIG_BLOCK.strip("\n"))
@@ -2804,9 +2933,9 @@ def main():
2804
2933
  help="Method to obtain Spotify access token: 'cookie' (via sp_dc cookie) or 'client' (via desktop client login protobuf)"
2805
2934
  )
2806
2935
 
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(
2936
+ # Auth details used when token source is set to cookie
2937
+ cookie_auth = parser.add_argument_group("Auth details for 'cookie' token source")
2938
+ cookie_auth.add_argument(
2810
2939
  "-u", "--spotify-dc-cookie",
2811
2940
  dest="spotify_dc_cookie",
2812
2941
  metavar="SP_DC_COOKIE",
@@ -2814,20 +2943,21 @@ def main():
2814
2943
  help="Spotify sp_dc cookie"
2815
2944
  )
2816
2945
 
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(
2946
+ # Auth details used when token source is set to client
2947
+ client_auth = parser.add_argument_group("Auth details for 'client' token source")
2948
+ client_auth.add_argument(
2820
2949
  "-w", "--login-request-body-file",
2821
2950
  dest="login_request_body_file",
2822
2951
  metavar="PROTOBUF_FILENAME",
2823
2952
  help="Read device_id, system_id, user_uri_id and refresh_token from binary Protobuf login file"
2824
2953
  )
2825
2954
 
2826
- client_creds.add_argument(
2955
+ client_auth.add_argument(
2827
2956
  "-z", "--clienttoken-request-body-file",
2828
2957
  dest="clienttoken_request_body_file",
2829
2958
  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"
2959
+ # help="Read app_version, cpu_arch, os_build, platform, os_major, os_minor and client_model from binary Protobuf client token file"
2960
+ help=argparse.SUPPRESS
2831
2961
  )
2832
2962
 
2833
2963
  # Notifications
@@ -2943,6 +3073,13 @@ def main():
2943
3073
  type=str,
2944
3074
  help="Filename with Spotify tracks/playlists/albums to alert on"
2945
3075
  )
3076
+ opts.add_argument(
3077
+ "--user-agent",
3078
+ dest="user_agent",
3079
+ metavar="USER_AGENT",
3080
+ type=str,
3081
+ help="Specify a custom user agent for Spotify API requests; leave empty to auto-generate it"
3082
+ )
2946
3083
  opts.add_argument(
2947
3084
  "-y", "--file-suffix",
2948
3085
  dest="file_suffix",
@@ -3006,7 +3143,7 @@ def main():
3006
3143
  except ImportError:
3007
3144
  env_path = DOTENV_FILE if DOTENV_FILE else None
3008
3145
  if env_path:
3009
- print(f"* Warning: Cannot load dotenv file '{env_path}' because 'python-dotenv' is not installed\n\nTo install it, run:\n pip3 install python-dotenv\n\nOnce installed, re-run this tool\n")
3146
+ print(f"* Warning: Cannot load dotenv file '{env_path}' because 'python-dotenv' is not installed\n\nTo install it, run:\n pip install python-dotenv\n\nOnce installed, re-run this tool\n")
3010
3147
 
3011
3148
  if env_path:
3012
3149
  for secret in SECRET_KEYS:
@@ -3025,7 +3162,16 @@ def main():
3025
3162
  try:
3026
3163
  import pyotp
3027
3164
  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")
3165
+ raise SystemExit("Error: Couldn't find the pyotp library !\n\nTo install it, run:\n pip install pyotp\n\nOnce installed, re-run this tool")
3166
+
3167
+ if args.user_agent:
3168
+ USER_AGENT = args.user_agent
3169
+
3170
+ if not USER_AGENT:
3171
+ if TOKEN_SOURCE == "client":
3172
+ USER_AGENT = get_random_spotify_user_agent()
3173
+ else:
3174
+ USER_AGENT = get_random_user_agent()
3029
3175
 
3030
3176
  if not check_internet():
3031
3177
  sys.exit(1)
@@ -3060,7 +3206,7 @@ def main():
3060
3206
  if LOGIN_REQUEST_BODY_FILE:
3061
3207
  if os.path.isfile(LOGIN_REQUEST_BODY_FILE):
3062
3208
  try:
3063
- DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_request_body_file(LOGIN_REQUEST_BODY_FILE)
3209
+ DEVICE_ID, SYSTEM_ID, USER_URI_ID, REFRESH_TOKEN = parse_login_request_body_file(LOGIN_REQUEST_BODY_FILE)
3064
3210
  except Exception as e:
3065
3211
  print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) cannot be processed: {e}")
3066
3212
  sys.exit(1)
@@ -3076,22 +3222,28 @@ def main():
3076
3222
  print(f"* Error: Protobuf file ({LOGIN_REQUEST_BODY_FILE}) does not exist")
3077
3223
  sys.exit(1)
3078
3224
 
3079
- if not USER_AGENT:
3080
- USER_AGENT = get_random_spotify_user_agent()
3225
+ vals = {
3226
+ "LOGIN_URL": LOGIN_URL,
3227
+ "USER_AGENT": USER_AGENT,
3228
+ "DEVICE_ID": DEVICE_ID,
3229
+ "SYSTEM_ID": SYSTEM_ID,
3230
+ "USER_URI_ID": USER_URI_ID,
3231
+ "REFRESH_TOKEN": REFRESH_TOKEN,
3232
+ }
3233
+ placeholders = {
3234
+ "DEVICE_ID": "your_spotify_app_device_id",
3235
+ "SYSTEM_ID": "your_spotify_app_system_id",
3236
+ "USER_URI_ID": "your_spotify_user_uri_id",
3237
+ "REFRESH_TOKEN": "your_spotify_app_refresh_token",
3238
+ }
3081
3239
 
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")
3240
+ bad = [
3241
+ f"{k} {'missing' if not v else 'is placeholder'}"
3242
+ for k, v in vals.items()
3243
+ if not v or placeholders.get(k) == v
3244
+ ]
3245
+ if bad:
3246
+ print("* Error:", "; ".join(bad))
3095
3247
  sys.exit(1)
3096
3248
 
3097
3249
  clienttoken_request_body_file_param = False
@@ -3130,7 +3282,7 @@ def main():
3130
3282
  try:
3131
3283
  APP_VERSION = ua_to_app_version(USER_AGENT)
3132
3284
  except Exception as e:
3133
- print(f"Warning: wrong USER_AGENT defined, reverting to the default one: {e}")
3285
+ print(f"Warning: wrong USER_AGENT defined, reverting to the default one for APP_VERSION: {e}")
3134
3286
  APP_VERSION = app_version_default
3135
3287
  else:
3136
3288
  APP_VERSION = app_version_default
@@ -3288,6 +3440,7 @@ def main():
3288
3440
  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
3441
  print(f"* Token source:\t\t\t{TOKEN_SOURCE}")
3290
3442
  print(f"* Track listened songs:\t\t{TRACK_SONGS}")
3443
+ # print(f"* User agent:\t\t\t{USER_AGENT}")
3291
3444
  print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
3292
3445
  print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
3293
3446
  print(f"* Alert on monitored tracks:\t{bool(MONITOR_LIST_FILE)}" + (f" ({MONITOR_LIST_FILE})" if MONITOR_LIST_FILE else ""))