spotify-monitor 2.1__tar.gz → 2.1.2__tar.gz

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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spotify_monitor
3
- Version: 2.1
3
+ Version: 2.1.2
4
4
  Summary: Tool implementing real-time tracking of Spotify friends music activity
5
5
  Author-email: Michal Szymanski <misiektoja-pypi@rm-rf.ninja>
6
6
  License-Expression: GPL-3.0-or-later
@@ -200,9 +200,9 @@ To use credentials captured from the Spotify desktop client to obtain an access
200
200
 
201
201
  Run an intercepting proxy of your choice (like [Proxyman](https://proxyman.com)).
202
202
 
203
- Launch the Spotify desktop client and look for requests to `https://login{n}.spotify.com/v3/login`
203
+ Launch the Spotify desktop client and look for POST requests to `https://login{n}.spotify.com/v3/login`
204
204
 
205
- Note: The `login` part is suffixed with one or more digits (e.g. `login5.spotify.com`).
205
+ Note: The `login` part is suffixed with one or more digits (e.g. `login5`).
206
206
 
207
207
  If you don't see this request, log out from the client and log back in.
208
208
 
@@ -175,9 +175,9 @@ To use credentials captured from the Spotify desktop client to obtain an access
175
175
 
176
176
  Run an intercepting proxy of your choice (like [Proxyman](https://proxyman.com)).
177
177
 
178
- Launch the Spotify desktop client and look for requests to `https://login{n}.spotify.com/v3/login`
178
+ Launch the Spotify desktop client and look for POST requests to `https://login{n}.spotify.com/v3/login`
179
179
 
180
- Note: The `login` part is suffixed with one or more digits (e.g. `login5.spotify.com`).
180
+ Note: The `login` part is suffixed with one or more digits (e.g. `login5`).
181
181
 
182
182
  If you don't see this request, log out from the client and log back in.
183
183
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spotify_monitor"
7
- version = "2.1"
7
+ version = "2.1.2"
8
8
  description = "Tool implementing real-time tracking of Spotify friends music activity"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spotify_monitor
3
- Version: 2.1
3
+ Version: 2.1.2
4
4
  Summary: Tool implementing real-time tracking of Spotify friends music activity
5
5
  Author-email: Michal Szymanski <misiektoja-pypi@rm-rf.ninja>
6
6
  License-Expression: GPL-3.0-or-later
@@ -200,9 +200,9 @@ To use credentials captured from the Spotify desktop client to obtain an access
200
200
 
201
201
  Run an intercepting proxy of your choice (like [Proxyman](https://proxyman.com)).
202
202
 
203
- Launch the Spotify desktop client and look for requests to `https://login{n}.spotify.com/v3/login`
203
+ Launch the Spotify desktop client and look for POST requests to `https://login{n}.spotify.com/v3/login`
204
204
 
205
- Note: The `login` part is suffixed with one or more digits (e.g. `login5.spotify.com`).
205
+ Note: The `login` part is suffixed with one or more digits (e.g. `login5`).
206
206
 
207
207
  If you don't see this request, log out from the client and log back in.
208
208
 
@@ -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
4
+ v2.1.2
5
5
 
6
6
  Tool implementing real-time tracking of Spotify friends music activity:
7
7
  https://github.com/misiektoja/spotify_monitor/
@@ -15,7 +15,7 @@ pyotp (needed when the token source is set to cookie)
15
15
  python-dotenv (optional)
16
16
  """
17
17
 
18
- VERSION = "2.1"
18
+ VERSION = "2.1.2"
19
19
 
20
20
  # ---------------------------
21
21
  # CONFIGURATION SECTION START
@@ -109,7 +109,7 @@ SPOTIFY_INACTIVITY_CHECK = 660 # 11 mins
109
109
  # Can also be set using the -m flag
110
110
  SPOTIFY_DISAPPEARED_CHECK_INTERVAL = 180 # 3 mins
111
111
 
112
- # Whether to autoplay each listened song in your Spotify client
112
+ # Whether to auto-play each listened song in your Spotify client
113
113
  # Can also be set using the -g flag
114
114
  TRACK_SONGS = False
115
115
 
@@ -202,7 +202,7 @@ SP_LOGFILE = "spotify_monitor"
202
202
  # Can also be disabled via the -d flag
203
203
  DISABLE_LOGGING = False
204
204
 
205
- # Width of horizontal line (─)
205
+ # Width of horizontal line
206
206
  HORIZONTAL_LINE = 113
207
207
 
208
208
  # Whether to clear the terminal screen after starting the tool
@@ -396,10 +396,7 @@ SP_CACHED_CLIENT_ID = ""
396
396
  SP_CACHED_USER_AGENT = ""
397
397
 
398
398
  # 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"
399
+ TOKEN_URL = "https://open.spotify.com/api/token"
403
400
 
404
401
  # Variables for caching functionality of the Spotify client token to avoid unnecessary refreshing
405
402
  SP_CACHED_CLIENT_TOKEN = None
@@ -451,6 +448,7 @@ import shutil
451
448
  from pathlib import Path
452
449
  import secrets
453
450
  from typing import Optional
451
+ from email.utils import parsedate_to_datetime
454
452
 
455
453
  import urllib3
456
454
  if not VERIFY_SSL:
@@ -512,7 +510,7 @@ def signal_handler(sig, frame):
512
510
  # Checks internet connectivity
513
511
  def check_internet(url=CHECK_INTERNET_URL, timeout=CHECK_INTERNET_TIMEOUT, verify=VERIFY_SSL):
514
512
  try:
515
- _ = req.get(url, timeout=timeout, verify=verify)
513
+ _ = req.get(url, headers={'User-Agent': get_random_user_agent() if TOKEN_SOURCE == 'cookie' else get_random_spotify_user_agent()}, timeout=timeout, verify=verify)
516
514
  return True
517
515
  except req.RequestException as e:
518
516
  print(f"* No connectivity, please check your network:\n\n{e}")
@@ -982,10 +980,14 @@ def check_token_validity(access_token: str, client_id: Optional[str] = None, use
982
980
  url = "https://api.spotify.com/v1/me"
983
981
  headers = {"Authorization": f"Bearer {access_token}"}
984
982
 
985
- if TOKEN_SOURCE == "cookie" and client_id is not None and user_agent is not None:
983
+ if user_agent is not None:
986
984
  headers.update({
987
- "Client-Id": client_id,
988
- "User-Agent": user_agent,
985
+ "User-Agent": user_agent
986
+ })
987
+
988
+ if TOKEN_SOURCE == "cookie" and client_id is not None:
989
+ headers.update({
990
+ "Client-Id": client_id
989
991
  })
990
992
 
991
993
  if platform.system() != 'Windows':
@@ -1087,30 +1089,10 @@ def get_random_user_agent() -> str:
1087
1089
  return ""
1088
1090
 
1089
1091
 
1090
- # Removes spaces from a hex string and converts it into a corresponding bytes object
1091
- def hex_to_bytes(data: str) -> bytes:
1092
- data = data.replace(" ", "")
1093
- return bytes.fromhex(data)
1094
-
1095
-
1096
- # Creates a TOTP object using a secret derived from transformed cipher bytes
1097
- def generate_totp(ua: str):
1098
- import pyotp
1099
-
1100
- secret_cipher_bytes = [
1101
- 12, 56, 76, 33, 88, 44, 88, 33,
1102
- 78, 78, 11, 66, 22, 22, 55, 69, 54,
1103
- ]
1104
-
1105
- transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
1106
- joined = "".join(str(num) for num in transformed)
1107
- utf8_bytes = joined.encode("utf-8")
1108
- hex_str = "".join(format(b, 'x') for b in utf8_bytes)
1109
- secret_bytes = hex_to_bytes(hex_str)
1110
- secret = base64.b32encode(secret_bytes).decode().rstrip('=')
1092
+ # Returns Spotify edge-server Unix time
1093
+ def fetch_server_time(session: req.Session, ua: str) -> int:
1111
1094
 
1112
1095
  headers = {
1113
- "Host": "open.spotify.com",
1114
1096
  "User-Agent": ua,
1115
1097
  "Accept": "*/*",
1116
1098
  }
@@ -1119,24 +1101,34 @@ def generate_totp(ua: str):
1119
1101
  if platform.system() != 'Windows':
1120
1102
  signal.signal(signal.SIGALRM, timeout_handler)
1121
1103
  signal.alarm(FUNCTION_TIMEOUT + 2)
1122
- resp = req.get(SERVER_TIME_URL, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1123
- except (req.RequestException, TimeoutException) as e:
1124
- raise Exception(f"generate_totp() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1104
+ response = session.head("https://open.spotify.com/", headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1105
+ response.raise_for_status()
1106
+ except TimeoutException as e:
1107
+ raise Exception(f"fetch_server_time() head network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1108
+ except Exception as e:
1109
+ raise Exception(f"fetch_server_time() head network request error: {e}")
1125
1110
  finally:
1126
1111
  if platform.system() != 'Windows':
1127
1112
  signal.alarm(0)
1128
1113
 
1129
- resp.raise_for_status()
1114
+ return int(parsedate_to_datetime(response.headers["Date"]).timestamp())
1130
1115
 
1131
- json_data = resp.json()
1132
- server_time = json_data.get("serverTime")
1133
1116
 
1134
- if server_time is None:
1135
- raise Exception("Failed to get server time")
1117
+ # Creates a TOTP object using a secret derived from transformed cipher bytes
1118
+ def generate_totp():
1119
+ import pyotp
1120
+
1121
+ secret_cipher_bytes = [
1122
+ 12, 56, 76, 33, 88, 44, 88, 33,
1123
+ 78, 78, 11, 66, 22, 22, 55, 69, 54,
1124
+ ]
1136
1125
 
1137
- totp_obj = pyotp.TOTP(secret, digits=6, interval=30)
1126
+ transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
1127
+ joined = "".join(str(num) for num in transformed)
1128
+ hex_str = joined.encode().hex()
1129
+ secret = base64.b32encode(bytes.fromhex(hex_str)).decode().rstrip("=")
1138
1130
 
1139
- return totp_obj, server_time
1131
+ return pyotp.TOTP(secret, digits=6, interval=30)
1140
1132
 
1141
1133
 
1142
1134
  # Retrieves a new Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
@@ -1149,7 +1141,8 @@ def refresh_token(sp_dc: str) -> dict:
1149
1141
  token = ""
1150
1142
 
1151
1143
  ua = get_random_user_agent()
1152
- totp_obj, server_time = generate_totp(ua)
1144
+ server_time = fetch_server_time(session, ua)
1145
+ totp_obj = generate_totp()
1153
1146
  client_time = int(time_ns() / 1000 / 1000)
1154
1147
  otp_value = totp_obj.at(server_time)
1155
1148
 
@@ -1161,11 +1154,15 @@ def refresh_token(sp_dc: str) -> dict:
1161
1154
  "totpVer": 5,
1162
1155
  "sTime": server_time,
1163
1156
  "cTime": client_time,
1157
+ "buildDate": time.strftime("%Y-%m-%d", time.gmtime(server_time)),
1158
+ "buildVer": f"web-player_{time.strftime('%Y-%m-%d', time.gmtime(server_time))}_{server_time * 1000}_{secrets.token_hex(4)}",
1164
1159
  }
1165
1160
 
1166
1161
  headers = {
1167
1162
  "User-Agent": ua,
1168
1163
  "Accept": "application/json",
1164
+ "Referer": "https://open.spotify.com/",
1165
+ "App-Platform": "WebPlayer",
1169
1166
  "Cookie": f"sp_dc={sp_dc}",
1170
1167
  }
1171
1168
 
@@ -1358,12 +1355,6 @@ def read_varint(data, index):
1358
1355
 
1359
1356
  # Parses Spotify Protobuf login response
1360
1357
  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
1358
  index = 0
1368
1359
  result = {}
1369
1360
  while index < len(data):
@@ -1401,7 +1392,6 @@ def parse_protobuf_message(data):
1401
1392
  # (device_id, system_id, user_uri_id, refresh_token)
1402
1393
  def parse_request_body_file(file_path):
1403
1394
  """
1404
- Expected structure:
1405
1395
  {
1406
1396
  1: {
1407
1397
  1: "device_id",
@@ -1566,7 +1556,7 @@ def build_clienttoken_request_protobuf(app_version, device_id, system_id, cpu_ar
1566
1556
  def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refresh_token, client_token):
1567
1557
  global SP_CACHED_ACCESS_TOKEN, SP_CACHED_REFRESH_TOKEN, SP_ACCESS_TOKEN_EXPIRES_AT
1568
1558
 
1569
- if SP_CACHED_ACCESS_TOKEN and time.time() < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN):
1559
+ if SP_CACHED_ACCESS_TOKEN and time.time() < SP_ACCESS_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, user_agent=USER_AGENT):
1570
1560
  return SP_CACHED_ACCESS_TOKEN
1571
1561
 
1572
1562
  if not client_token:
@@ -1578,8 +1568,8 @@ def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refr
1578
1568
  protobuf_body = build_spotify_auth_protobuf(device_id, system_id, user_uri_id, refresh_token)
1579
1569
 
1580
1570
  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"
1571
+ host = parsed_url.netloc
1572
+ origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
1583
1573
 
1584
1574
  headers = {
1585
1575
  "Host": host,
@@ -1601,8 +1591,10 @@ def spotify_get_access_token_from_client(device_id, system_id, user_uri_id, refr
1601
1591
  signal.signal(signal.SIGALRM, timeout_handler)
1602
1592
  signal.alarm(FUNCTION_TIMEOUT + 2)
1603
1593
  response = req.post(LOGIN_URL, headers=headers, data=protobuf_body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1604
- except (req.RequestException, TimeoutException) as e:
1594
+ except TimeoutException as e:
1605
1595
  raise Exception(f"spotify_get_access_token_from_client() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1596
+ except Exception as e:
1597
+ raise Exception(f"spotify_get_access_token_from_client() network request error: {e}")
1606
1598
  finally:
1607
1599
  if platform.system() != 'Windows':
1608
1600
  signal.alarm(0)
@@ -1673,8 +1665,10 @@ def spotify_get_client_token(app_version, device_id, system_id, **device_overrid
1673
1665
  signal.signal(signal.SIGALRM, timeout_handler)
1674
1666
  signal.alarm(FUNCTION_TIMEOUT + 2)
1675
1667
  response = req.post(CLIENTTOKEN_URL, headers=headers, data=body, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1676
- except (req.RequestException, TimeoutException) as e:
1668
+ except TimeoutException as e:
1677
1669
  raise Exception(f"spotify_get_client_token() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
1670
+ except Exception as e:
1671
+ raise Exception(f"spotify_get_client_token() network request error: {e}")
1678
1672
  finally:
1679
1673
  if platform.system() != 'Windows':
1680
1674
  signal.alarm(0)
@@ -1685,7 +1679,7 @@ def spotify_get_client_token(app_version, device_id, system_id, **device_overrid
1685
1679
  parsed = parse_protobuf_message(response.content)
1686
1680
  inner = parsed.get(2, {})
1687
1681
  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
1682
+ ttl = int(inner.get(3, 0)) or 1209600
1689
1683
 
1690
1684
  if not client_token:
1691
1685
  raise Exception("clienttoken response did not contain a token")
@@ -1748,6 +1742,10 @@ def spotify_get_friends_json(access_token):
1748
1742
  "Client-Id": SP_CACHED_CLIENT_ID,
1749
1743
  "User-Agent": SP_CACHED_USER_AGENT,
1750
1744
  })
1745
+ elif TOKEN_SOURCE == "client":
1746
+ headers.update({
1747
+ "User-Agent": USER_AGENT
1748
+ })
1751
1749
 
1752
1750
  response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1753
1751
  if response.status_code == 401:
@@ -1807,8 +1805,6 @@ def spotify_list_friends(friend_activity):
1807
1805
  sp_playlist_uri = friend["track"]["context"].get("uri")
1808
1806
  sp_track_uri = friend["track"].get("uri")
1809
1807
 
1810
- # if index > 0:
1811
- # print("─" * HORIZONTAL_LINE)
1812
1808
  print("─" * HORIZONTAL_LINE)
1813
1809
  print(f"Username:\t\t\t{sp_username}")
1814
1810
  print(f"User URI ID:\t\t\t{sp_uri}")
@@ -1877,6 +1873,10 @@ def spotify_get_track_info(access_token, track_uri):
1877
1873
  "Client-Id": SP_CACHED_CLIENT_ID,
1878
1874
  "User-Agent": SP_CACHED_USER_AGENT,
1879
1875
  })
1876
+ elif TOKEN_SOURCE == "client":
1877
+ headers.update({
1878
+ "User-Agent": USER_AGENT
1879
+ })
1880
1880
  # add si parameter so link opens in native Spotify app after clicking
1881
1881
  si = "?si=1"
1882
1882
 
@@ -1907,6 +1907,10 @@ def spotify_get_playlist_info(access_token, playlist_uri):
1907
1907
  "Client-Id": SP_CACHED_CLIENT_ID,
1908
1908
  "User-Agent": SP_CACHED_USER_AGENT,
1909
1909
  })
1910
+ elif TOKEN_SOURCE == "client":
1911
+ headers.update({
1912
+ "User-Agent": USER_AGENT
1913
+ })
1910
1914
  # add si parameter so link opens in native Spotify app after clicking
1911
1915
  si = "?si=1"
1912
1916
 
@@ -1934,6 +1938,10 @@ def spotify_get_current_user(access_token) -> dict | None:
1934
1938
  "Client-Id": SP_CACHED_CLIENT_ID,
1935
1939
  "User-Agent": SP_CACHED_USER_AGENT,
1936
1940
  })
1941
+ elif TOKEN_SOURCE == "client":
1942
+ headers.update({
1943
+ "User-Agent": USER_AGENT
1944
+ })
1937
1945
 
1938
1946
  if platform.system() != 'Windows':
1939
1947
  signal.signal(signal.SIGALRM, timeout_handler)
@@ -2916,7 +2924,7 @@ def main():
2916
2924
  dest="track_in_spotify",
2917
2925
  action="store_true",
2918
2926
  default=None,
2919
- help="Autoplay each listened song in your Spotify client"
2927
+ help="Auto-play each listened song in your Spotify client"
2920
2928
  )
2921
2929
  opts.add_argument(
2922
2930
  "-b", "--csv-file",
File without changes
File without changes