spotify-monitor 2.2.1__py3-none-any.whl → 2.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of spotify-monitor might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spotify_monitor
3
- Version: 2.2.1
3
+ Version: 2.3.1
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
@@ -21,13 +21,16 @@ Requires-Dist: python-dateutil>=2.8
21
21
  Requires-Dist: urllib3>=2.0.7
22
22
  Requires-Dist: pyotp>=2.9.0
23
23
  Requires-Dist: python-dotenv>=0.19
24
+ Requires-Dist: wcwidth>=0.2.7
24
25
  Dynamic: license-file
25
26
 
26
27
  # spotify_monitor
27
28
 
28
29
  Tool for real-time monitoring of Spotify friends' music activity feed.
29
30
 
30
- NOTE: If you're interested in tracking changes to Spotify users' profiles including their playlists, take a look at another tool I've developed: [spotify_profile_monitor](https://github.com/misiektoja/spotify_profile_monitor).
31
+ If you're interested in tracking changes to Spotify users' profiles including their playlists, take a look at another tool I've developed: [spotify_profile_monitor](https://github.com/misiektoja/spotify_profile_monitor).
32
+
33
+ 🛠️ If you're looking for debug tools to get Spotify Web Player access tokens and extract secret keys: [click here](#debugging-tools)
31
34
 
32
35
  <a id="features"></a>
33
36
  ## Features
@@ -73,14 +76,15 @@ NOTE: If you're interested in tracking changes to Spotify users' profiles includ
73
76
  * [Check Intervals](#check-intervals)
74
77
  * [Signal Controls (macOS/Linux/Unix)](#signal-controls-macoslinuxunix)
75
78
  * [Coloring Log Output with GRC](#coloring-log-output-with-grc)
76
- 6. [Change Log](#change-log)
77
- 7. [License](#license)
79
+ 6. [Debugging Tools](#debugging-tools)
80
+ 7. [Change Log](#change-log)
81
+ 8. [License](#license)
78
82
 
79
83
  <a id="requirements"></a>
80
84
  ## Requirements
81
85
 
82
86
  * Python 3.6 or higher
83
- * Libraries: `requests`, `python-dateutil`, `urllib3`, `pyotp`, `python-dotenv`
87
+ * Libraries: `requests`, `python-dateutil`, `urllib3`, `pyotp`, `python-dotenv`, `wcwidth`
84
88
 
85
89
  Tested on:
86
90
 
@@ -195,19 +199,30 @@ If your `sp_dc` cookie expires, the tool will notify you via the console and ema
195
199
 
196
200
  If you store the `SP_DC_COOKIE` in a dotenv file you can update its value and send a `SIGHUP` signal to reload the file with the new `sp_dc` cookie without restarting the tool. More info in [Storing Secrets](#storing-secrets) and [Signal Controls (macOS/Linux/Unix)](#signal-controls-macoslinuxunix).
197
201
 
202
+ `Note`: encrypted byte sequences used for TOTP secret generation tend to expire every now and then; you can either check the [issues](https://github.com/misiektoja/spotify_monitor/issues) section of the project to see if there are any new secrets published or you can run the [spotify_monitor_secret_grabber.py](https://github.com/misiektoja/spotify_monitor/blob/dev/debug/spotify_monitor_secret_grabber.py) and extract it by yourself (see [Debugging Tools](https://github.com/misiektoja/spotify_monitor#debugging-tools) for more info).
203
+
198
204
  <a id="spotify-desktop-client"></a>
199
205
  #### Spotify Desktop Client
200
206
 
201
207
  This is the alternative method used to obtain a Spotify access token which simulates a login from the real Spotify desktop app using credentials intercepted from a real session.
202
208
 
203
- **NOTE**: Spotify appears to have changed something in client versions released after June 2025 (likely a switch to HTTP/3 and/or certificate pinning). You may need to use an older version of the Spotify desktop client for this method to work.
209
+ - Run an intercepting proxy of your choice (like [Proxyman](https://proxyman.com) - the trial version is sufficient)
204
210
 
205
- - Run an intercepting proxy of your choice (like [Proxyman](https://proxyman.com)).
211
+ - Enable SSL traffic decryption for `spotify.com` domain
212
+ - in Proxyman: click **Tools → SSL Proxying List → + button → Add Domain → paste `*.spotify.com` → Add**
206
213
 
207
- - Launch the Spotify desktop client and look for POST requests to `https://login{n}.spotify.com/v3/login`
208
- - Note: The `login` part is suffixed with one or more digits (e.g. `login5`).
214
+ - Launch the Spotify desktop client, then switch to your intercepting proxy (like Proxyman) and look for POST requests to `https://login5.spotify.com/v3/login`
209
215
 
210
- - If you don't see this request, log out from the Spotify desktop client and log back in.
216
+ - If you don't see this request, try following steps (stop once it works):
217
+ - restart the Spotify desktop client
218
+ - log out from the Spotify desktop client and log back in
219
+ - point Spotify at the intercepting proxy directly in its settings, i.e. in **Spotify → Settings → Proxy Settings**, set:
220
+ - **proxy type**: `HTTP`
221
+ - **host**: `127.0.0.1` (IP/FQDN of your proxy, for Proxyman use the IP you see at the top bar)
222
+ - **port**: `9090` (port of your proxy, for Proxyman use the port you see at the top bar)
223
+ - restart the app; since QUIC (HTTP/3) requires raw UDP and can't tunnel over HTTP CONNECT, Spotify will downgrade to TCP-only HTTP/2 or 1.1, which intercepting proxy can decrypt
224
+ - block Spotify's UDP port 443 at the OS level with a firewall of your choice - this prevents QUIC (HTTP/3), forcing TLS over TCP and letting intercepting proxy perform MITM
225
+ - try an older version of the Spotify desktop client
211
226
 
212
227
  - Export the login request body (a binary Protobuf payload) to a file (e.g. ***login-request-body-file***)
213
228
  - In Proxyman: **right click the request → Export → Request Body → Save File**.
@@ -554,6 +569,30 @@ Example:
554
569
  grc tail -F -n 100 spotify_monitor_<user_uri_id/file_suffix>.log
555
570
  ```
556
571
 
572
+ <a id="debugging-tools"></a>
573
+ ## Debugging Tools
574
+
575
+ To help with troubleshooting and development, two debug utilities are available in the `debug` directory:
576
+
577
+ - [spotify_monitor_totp_test.py](https://github.com/misiektoja/spotify_monitor/blob/dev/debug/spotify_monitor_totp_test.py): fetching of Spotify access token based on a Spotify Web Player `sp_dc` cookie value:
578
+
579
+ ```sh
580
+ pip install requests python-dateutil pyotp
581
+ python3 spotify_monitor_totp_test.py --sp-dc "your_sp_dc_cookie_value"
582
+ ```
583
+
584
+ - [spotify_monitor_secret_grabber.py](https://github.com/misiektoja/spotify_monitor/blob/dev/debug/spotify_monitor_secret_grabber.py): automatic extractor for secret keys used for TOTP generation in Spotify Web Player JavaScript bundles:
585
+
586
+ ```sh
587
+ pip install playwright
588
+ playwright install
589
+ python3 spotify_monitor_secret_grabber.py
590
+ ```
591
+
592
+ <p align="center">
593
+ <img src="https://raw.githubusercontent.com/misiektoja/spotify_monitor/refs/heads/main/assets/spotify_monitor_secret_grabber.png" alt="spotify_monitor_secret_grabber" width="100%"/>
594
+ </p>
595
+
557
596
  <a id="change-log"></a>
558
597
  ## Change Log
559
598
 
@@ -0,0 +1,7 @@
1
+ spotify_monitor.py,sha256=msF-xUpmB_dch3C1dtA31mvU0OgtRUgUeOtXh20xDLw,159386
2
+ spotify_monitor-2.3.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ spotify_monitor-2.3.1.dist-info/METADATA,sha256=z37aFQ2NUa5jYzU5pF6ru5UXmu65Ctee8J_EDvjG1iI,25204
4
+ spotify_monitor-2.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ spotify_monitor-2.3.1.dist-info/entry_points.txt,sha256=8HzePfUcCSXrYaXOwLbNNYO8GJcnhgCSl4wcDNECht8,57
6
+ spotify_monitor-2.3.1.dist-info/top_level.txt,sha256=EP6IPD4vHT12rLM5b_jo2i3nrfOuwk3ehhr2gWdQx9Y,16
7
+ spotify_monitor-2.3.1.dist-info/RECORD,,
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.2.1
4
+ v2.3.1
5
5
 
6
6
  Tool implementing real-time tracking of Spotify friends music activity:
7
7
  https://github.com/misiektoja/spotify_monitor/
@@ -13,9 +13,10 @@ python-dateutil
13
13
  urllib3
14
14
  pyotp (optional, needed when the token source is set to cookie)
15
15
  python-dotenv (optional)
16
+ wcwidth (optional, needed by TRUNCATE_CHARS feature)
16
17
  """
17
18
 
18
- VERSION = "2.2.1"
19
+ VERSION = "2.3.1"
19
20
 
20
21
  # ---------------------------
21
22
  # CONFIGURATION SECTION START
@@ -223,17 +224,48 @@ HORIZONTAL_LINE = 113
223
224
  # Whether to clear the terminal screen after starting the tool
224
225
  CLEAR_SCREEN = True
225
226
 
227
+ # Path to a file that is created when the user is active and deleted when inactive
228
+ # Useful for external tools to detect streaming status
229
+ # Can also be set via the --flag-file flag
230
+ FLAG_FILE = ""
231
+
232
+ # Max characters per line when printing to screen to avoid line wrapping
233
+ # Does not affect log file output
234
+ # Set to 999 to auto-detect terminal width
235
+ # Applies only when DISABLE_LOGGING is False
236
+ # Can also be set via the --truncate flag
237
+ TRUNCATE_CHARS = 0
238
+
226
239
  # Value added/subtracted via signal handlers to adjust inactivity timeout (SPOTIFY_INACTIVITY_CHECK); in seconds
227
240
  SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE = 30 # 30 seconds
228
241
 
242
+ # ---------------------------------------------------------------------
243
+
244
+ # The section below is used when the token source is set to 'cookie'
245
+
229
246
  # Maximum number of attempts to get a valid access token in a single run of the spotify_get_access_token_from_sp_dc() function
230
- # Used only when the token source is set to 'cookie'
231
247
  TOKEN_MAX_RETRIES = 10
232
248
 
233
249
  # Interval between access token retry attempts; in seconds
234
- # Used only when the token source is set to 'cookie'
235
250
  TOKEN_RETRY_TIMEOUT = 0.5 # 0.5 second
236
251
 
252
+ # Mapping of TOTP version identifiers to the encrypted byte sequence for TOTP secret generation
253
+ # Newest TOTP secrets can be fetched via spotify_monitor_secret_grabber.py (see debug dir)
254
+ SECRET_CIPHER_DICT = {
255
+ "12": [107, 81, 49, 57, 67, 93, 87, 81, 69, 67, 40, 93, 48, 50, 46, 91, 94, 113, 41, 108, 77, 107, 34],
256
+ "11": [111, 45, 40, 73, 95, 74, 35, 85, 105, 107, 60, 110, 55, 72, 69, 70, 114, 83, 63, 88, 91],
257
+ "10": [61, 110, 58, 98, 35, 79, 117, 69, 102, 72, 92, 102, 69, 93, 41, 101, 42, 75],
258
+ "9": [109, 101, 90, 99, 66, 92, 116, 108, 85, 70, 86, 49, 68, 54, 87, 50, 72, 121, 52, 64, 57, 43, 36, 81, 97, 72, 53, 41, 78, 56],
259
+ "8": [37, 84, 32, 76, 87, 90, 87, 47, 13, 75, 48, 54, 44, 28, 19, 21, 22],
260
+ "7": [59, 91, 66, 74, 30, 66, 74, 38, 46, 50, 72, 61, 44, 71, 86, 39, 89],
261
+ "6": [21, 24, 85, 46, 48, 35, 33, 8, 11, 63, 76, 12, 55, 77, 14, 7, 54],
262
+ "5": [12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54],
263
+ }
264
+
265
+ # Identifier used to select the appropriate encrypted secret from SECRET_CIPHER_DICT when generating a TOTP token
266
+ # Set to 0 to auto-select the highest available version
267
+ TOTP_VER = 0
268
+
237
269
  # ---------------------------------------------------------------------
238
270
 
239
271
  # The section below is used when the token source is set to 'client'
@@ -434,6 +466,10 @@ CLEAR_SCREEN = False
434
466
  SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE = 0
435
467
  TOKEN_MAX_RETRIES = 0
436
468
  TOKEN_RETRY_TIMEOUT = 0.0
469
+ SECRET_CIPHER_DICT = {}
470
+ TOTP_VER = 0
471
+ FLAG_FILE = ""
472
+ TRUNCATE_CHARS = 0
437
473
 
438
474
  exec(CONFIG_BLOCK, globals())
439
475
 
@@ -463,6 +499,9 @@ SP_CACHED_CLIENT_ID = ""
463
499
  # URL of the Spotify Web Player endpoint to get access token
464
500
  TOKEN_URL = "https://open.spotify.com/api/token"
465
501
 
502
+ # URL of the endpoint to get server time needed to create TOTP object
503
+ SERVER_TIME_URL = "https://open.spotify.com/"
504
+
466
505
  # Variables for caching functionality of the Spotify client token to avoid unnecessary refreshing
467
506
  SP_CACHED_CLIENT_TOKEN = None
468
507
  SP_CLIENT_TOKEN_EXPIRES_AT = 0
@@ -540,6 +579,35 @@ SESSION.mount("https://", adapter)
540
579
  SESSION.mount("http://", adapter)
541
580
 
542
581
 
582
+ # Truncates each line of a string to a specified number of characters including tab expansion and multi-line support
583
+ def truncate_string_per_line(message, truncate_width, tabsize=8):
584
+ try:
585
+ from wcwidth import wcwidth
586
+ except ImportError:
587
+ return message
588
+
589
+ lines = message.split('\n')
590
+ truncated_lines = []
591
+
592
+ for line in lines:
593
+ expanded_line = line.expandtabs(tabsize)
594
+ current_width = 0
595
+ truncated = ''
596
+
597
+ for char in expanded_line:
598
+ char_width = wcwidth(char)
599
+ if char_width < 0:
600
+ char_width = 0 # Non-printable or unknown width
601
+ if current_width + char_width > truncate_width:
602
+ break
603
+ truncated += char
604
+ current_width += char_width
605
+
606
+ truncated_lines.append(truncated)
607
+
608
+ return '\n'.join(truncated_lines)
609
+
610
+
543
611
  # Logger class to output messages to stdout and log file
544
612
  class Logger(object):
545
613
  def __init__(self, filename):
@@ -547,8 +615,10 @@ class Logger(object):
547
615
  self.logfile = open(filename, "a", buffering=1, encoding="utf-8")
548
616
 
549
617
  def write(self, message):
550
- self.terminal.write(message)
551
618
  self.logfile.write(message)
619
+ if (TRUNCATE_CHARS):
620
+ message = truncate_string_per_line(message, TRUNCATE_CHARS)
621
+ self.terminal.write(message)
552
622
  self.terminal.flush()
553
623
  self.logfile.flush()
554
624
 
@@ -556,6 +626,22 @@ class Logger(object):
556
626
  pass
557
627
 
558
628
 
629
+ def flag_file_create():
630
+ try:
631
+ with open(FLAG_FILE, "w") as f:
632
+ f.write("This indicates active streaming by monitored user")
633
+ except Exception:
634
+ pass
635
+
636
+
637
+ def flag_file_delete():
638
+ try:
639
+ if os.path.exists(FLAG_FILE):
640
+ os.remove(FLAG_FILE)
641
+ except Exception:
642
+ pass
643
+
644
+
559
645
  # Class used to generate timeout exceptions
560
646
  class TimeoutException(Exception):
561
647
  pass
@@ -570,6 +656,8 @@ def timeout_handler(sig, frame):
570
656
  def signal_handler(sig, frame):
571
657
  sys.stdout = stdout_bck
572
658
  print('\n* You pressed Ctrl+C, tool is terminated.')
659
+ if FLAG_FILE:
660
+ flag_file_delete()
573
661
  sys.exit(0)
574
662
 
575
663
 
@@ -1167,7 +1255,7 @@ def fetch_server_time(session: req.Session, ua: str) -> int:
1167
1255
  if platform.system() != 'Windows':
1168
1256
  signal.signal(signal.SIGALRM, timeout_handler)
1169
1257
  signal.alarm(FUNCTION_TIMEOUT + 2)
1170
- response = session.head("https://open.spotify.com/", headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1258
+ response = session.head(SERVER_TIME_URL, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1171
1259
  response.raise_for_status()
1172
1260
  except TimeoutException as e:
1173
1261
  raise Exception(f"fetch_server_time() head network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
@@ -1177,20 +1265,18 @@ def fetch_server_time(session: req.Session, ua: str) -> int:
1177
1265
  if platform.system() != 'Windows':
1178
1266
  signal.alarm(0)
1179
1267
 
1180
- return int(parsedate_to_datetime(response.headers["Date"]).timestamp())
1268
+ date_hdr = response.headers.get("Date")
1269
+ if not date_hdr:
1270
+ raise Exception("fetch_server_time() missing 'Date' header")
1271
+
1272
+ return int(parsedate_to_datetime(date_hdr).timestamp())
1181
1273
 
1182
1274
 
1183
1275
  # Creates a TOTP object using a secret derived from transformed cipher bytes
1184
1276
  def generate_totp():
1185
1277
  import pyotp
1186
1278
 
1187
- secret_cipher_dict = {
1188
- "8": [37, 84, 32, 76, 87, 90, 87, 47, 13, 75, 48, 54, 44, 28, 19, 21, 22],
1189
- "7": [59, 91, 66, 74, 30, 66, 74, 38, 46, 50, 72, 61, 44, 71, 86, 39, 89],
1190
- "6": [21, 24, 85, 46, 48, 35, 33, 8, 11, 63, 76, 12, 55, 77, 14, 7, 54],
1191
- "5": [12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54]
1192
- }
1193
- secret_cipher_bytes = secret_cipher_dict["8"]
1279
+ secret_cipher_bytes = SECRET_CIPHER_DICT[str((ver := TOTP_VER or max(map(int, SECRET_CIPHER_DICT))))]
1194
1280
 
1195
1281
  transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
1196
1282
  joined = "".join(str(num) for num in transformed)
@@ -1205,7 +1291,6 @@ def refresh_access_token_from_sp_dc(sp_dc: str) -> dict:
1205
1291
  transport = True
1206
1292
  init = True
1207
1293
  session = req.Session()
1208
- session.cookies.set("sp_dc", sp_dc)
1209
1294
  data: dict = {}
1210
1295
  token = ""
1211
1296
 
@@ -1219,13 +1304,17 @@ def refresh_access_token_from_sp_dc(sp_dc: str) -> dict:
1219
1304
  "productType": "web-player",
1220
1305
  "totp": otp_value,
1221
1306
  "totpServer": otp_value,
1222
- "totpVer": 8,
1223
- "sTime": server_time,
1224
- "cTime": client_time,
1225
- "buildDate": time.strftime("%Y-%m-%d", time.gmtime(server_time)),
1226
- "buildVer": f"web-player_{time.strftime('%Y-%m-%d', time.gmtime(server_time))}_{server_time * 1000}_{secrets.token_hex(4)}",
1307
+ "totpVer": TOTP_VER,
1227
1308
  }
1228
1309
 
1310
+ if TOTP_VER < 10:
1311
+ params.update({
1312
+ "sTime": server_time,
1313
+ "cTime": client_time,
1314
+ "buildDate": time.strftime("%Y-%m-%d", time.gmtime(server_time)),
1315
+ "buildVer": f"web-player_{time.strftime('%Y-%m-%d', time.gmtime(server_time))}_{server_time * 1000}_{secrets.token_hex(4)}",
1316
+ })
1317
+
1229
1318
  headers = {
1230
1319
  "User-Agent": USER_AGENT,
1231
1320
  "Accept": "application/json",
@@ -2402,6 +2491,9 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2402
2491
  song_on_loop = 1
2403
2492
  print("\n*** Friend is currently ACTIVE !")
2404
2493
 
2494
+ if FLAG_FILE:
2495
+ flag_file_create()
2496
+
2405
2497
  if sp_track.upper() in tracks_upper or sp_playlist.upper() in tracks_upper or sp_album.upper() in tracks_upper:
2406
2498
  print("*** Track/playlist/album matched with the list!")
2407
2499
 
@@ -2413,8 +2505,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2413
2505
 
2414
2506
  if ACTIVE_NOTIFICATION:
2415
2507
  m_subject = f"Spotify user {sp_username} is active: '{sp_artist} - {sp_track}'"
2416
- 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: ')}"
2417
- 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>"
2508
+ 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\nSongs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2509
+ 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>Songs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2418
2510
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2419
2511
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2420
2512
 
@@ -2431,6 +2523,9 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2431
2523
  sp_active_ts_stop = sp_ts
2432
2524
  print(f"\n*** Friend is OFFLINE for: {calculate_timespan(int(cur_ts), int(sp_ts))}")
2433
2525
 
2526
+ if listened_songs:
2527
+ print(f"\nSongs played:\t\t\t{listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})")
2528
+
2434
2529
  print(f"\nTracks/playlists/albums to monitor: {tracks}")
2435
2530
  print_cur_ts("\nTimestamp:\t\t\t")
2436
2531
 
@@ -2722,6 +2817,9 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2722
2817
  looped_songs = 0
2723
2818
  song_on_loop = 1
2724
2819
 
2820
+ if FLAG_FILE:
2821
+ flag_file_create()
2822
+
2725
2823
  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)})")
2726
2824
  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)})"
2727
2825
  friend_active_m_body = f"\n\nFriend got active after being offline for {calculate_timespan(int(sp_active_ts_start), int(sp_active_ts_stop))}\nLast activity (before getting offline): {get_date_from_ts(sp_active_ts_stop)}"
@@ -2737,8 +2835,8 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2737
2835
  sp_active_ts_start = sp_active_ts_start_old
2738
2836
  sp_active_ts_stop = 0
2739
2837
 
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}{friend_active_m_body}\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>{friend_active_m_body_html}<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2838
+ 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\nSongs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2839
+ 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>Songs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2742
2840
 
2743
2841
  if ACTIVE_NOTIFICATION:
2744
2842
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2752,16 +2850,16 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2752
2850
 
2753
2851
  if (TRACK_NOTIFICATION and on_the_list and not email_sent) or (SONG_NOTIFICATION and not email_sent):
2754
2852
  m_subject = f"Spotify user {sp_username}: '{sp_artist} - {sp_track}'"
2755
- 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: ')}"
2756
- 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>"
2853
+ 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\nSongs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2854
+ 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>Songs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2757
2855
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2758
2856
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
2759
2857
  email_sent = True
2760
2858
 
2761
2859
  if song_on_loop == SONG_ON_LOOP_VALUE and SONG_ON_LOOP_NOTIFICATION:
2762
2860
  m_subject = f"Spotify user {sp_username} plays song on loop: '{sp_artist} - {sp_track}'"
2763
- 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: ')}"
2764
- 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>"
2861
+ 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\nSongs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})\n\nLast activity: {get_date_from_ts(sp_ts)}{get_cur_ts(nl_ch + 'Timestamp: ')}"
2862
+ 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>Songs played: {listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})<br><br>Last activity: {get_date_from_ts(sp_ts)}{get_cur_ts('<br>Timestamp: ')}</body></html>"
2765
2863
  if not email_sent:
2766
2864
  print(f"Sending email notification to {RECEIVER_EMAIL}")
2767
2865
  send_email(m_subject, m_body, m_body_html, SMTP_SSL)
@@ -2772,6 +2870,9 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2772
2870
  except Exception as e:
2773
2871
  print(f"* Error: {e}")
2774
2872
 
2873
+ if listened_songs:
2874
+ print(f"\nSongs played:\t\t\t{listened_songs} ({calculate_timespan(int(sp_ts), int(sp_active_ts_start))})")
2875
+
2775
2876
  print_cur_ts("\nTimestamp:\t\t\t")
2776
2877
  sp_ts_old = sp_ts
2777
2878
  # Track has not changed
@@ -2783,6 +2884,9 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2783
2884
  print(f"*** Friend got INACTIVE after listening to music for {calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}")
2784
2885
  print(f"*** Friend played music from {get_range_of_dates_from_tss(sp_active_ts_start, sp_active_ts_stop, short=True, between_sep=' to ')}")
2785
2886
 
2887
+ if FLAG_FILE:
2888
+ flag_file_delete()
2889
+
2786
2890
  listened_songs_text = f"*** User played {listened_songs} songs"
2787
2891
  listened_songs_mbody = f"\n\nUser played {listened_songs} songs"
2788
2892
  listened_songs_mbody_html = f"<br><br>User played <b>{listened_songs}</b> songs"
@@ -2880,7 +2984,7 @@ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
2880
2984
 
2881
2985
 
2882
2986
  def main():
2883
- 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
2987
+ 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, FLAG_FILE, TRUNCATE_CHARS
2884
2988
 
2885
2989
  if "--generate-config" in sys.argv:
2886
2990
  print(CONFIG_BLOCK.strip("\n"))
@@ -3088,6 +3192,12 @@ def main():
3088
3192
  type=str,
3089
3193
  help="Filename with Spotify tracks/playlists/albums to alert on"
3090
3194
  )
3195
+ opts.add_argument(
3196
+ "--flag-file",
3197
+ dest="flag_file",
3198
+ metavar="PATH",
3199
+ help="Path to flag file that is created when the user is active and deleted when inactive",
3200
+ )
3091
3201
  opts.add_argument(
3092
3202
  "--user-agent",
3093
3203
  dest="user_agent",
@@ -3109,6 +3219,13 @@ def main():
3109
3219
  default=None,
3110
3220
  help="Disable logging to spotify_monitor_<user_uri_id/file_suffix>.log"
3111
3221
  )
3222
+ opts.add_argument(
3223
+ "--truncate",
3224
+ dest="truncate",
3225
+ metavar="N",
3226
+ type=int,
3227
+ help="Max characters per screen line (not log), use 999 to auto-detect terminal width, ignored if -d is set"
3228
+ )
3112
3229
 
3113
3230
  args = parser.parse_args()
3114
3231
 
@@ -3191,6 +3308,13 @@ def main():
3191
3308
  if not check_internet():
3192
3309
  sys.exit(1)
3193
3310
 
3311
+ if args.flag_file:
3312
+ FLAG_FILE = os.path.expanduser(args.flag_file)
3313
+ flag_file_delete()
3314
+ else:
3315
+ if FLAG_FILE:
3316
+ FLAG_FILE = os.path.expanduser(FLAG_FILE)
3317
+
3194
3318
  if args.send_test_email:
3195
3319
  print("* Sending test email notification ...\n")
3196
3320
  if send_email("spotify_monitor: test email", "This is test email - your SMTP settings seems to be correct !", "", SMTP_SSL, smtp_timeout=5) == 0:
@@ -3405,6 +3529,18 @@ def main():
3405
3529
  if not FILE_SUFFIX:
3406
3530
  FILE_SUFFIX = str(args.user_id)
3407
3531
 
3532
+ if args.truncate:
3533
+ if args.truncate != 999:
3534
+ TRUNCATE_CHARS = args.truncate
3535
+ else:
3536
+ try:
3537
+ terminal_size = shutil.get_terminal_size()
3538
+ print(f"The detected terminal screen width is: {terminal_size.columns} characters\n")
3539
+ TRUNCATE_CHARS = terminal_size.columns
3540
+ except Exception as e:
3541
+ print(f"Error: Cannot determine terminal screen width: {e}")
3542
+ sys.exit(1)
3543
+
3408
3544
  if args.disable_logging is True:
3409
3545
  DISABLE_LOGGING = True
3410
3546
 
@@ -3451,7 +3587,7 @@ def main():
3451
3587
  SONG_ON_LOOP_NOTIFICATION = False
3452
3588
  ERROR_NOTIFICATION = False
3453
3589
 
3454
- print(f"* Spotify polling intervals:\t[check: {display_time(SPOTIFY_CHECK_INTERVAL)}] [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]\n\t\t\t\t[disappeared: {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)}] [error: {display_time(SPOTIFY_ERROR_INTERVAL)}]")
3590
+ print(f"* Spotify polling intervals:\t[check: {display_time(SPOTIFY_CHECK_INTERVAL)}] [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]\n*\t\t\t\t[disappeared: {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)}] [error: {display_time(SPOTIFY_ERROR_INTERVAL)}]")
3455
3591
  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}]")
3456
3592
  print(f"* Token source:\t\t\t{TOKEN_SOURCE}")
3457
3593
  print(f"* Track listened songs:\t\t{TRACK_SONGS}")
@@ -3460,6 +3596,10 @@ def main():
3460
3596
  print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
3461
3597
  print(f"* Alert on monitored tracks:\t{bool(MONITOR_LIST_FILE)}" + (f" ({MONITOR_LIST_FILE})" if MONITOR_LIST_FILE else ""))
3462
3598
  print(f"* Output logging enabled:\t{not DISABLE_LOGGING}" + (f" ({FINAL_LOG_PATH})" if not DISABLE_LOGGING else ""))
3599
+ if not DISABLE_LOGGING and TRUNCATE_CHARS > 0:
3600
+ print(f"* Truncate terminal lines:\t{TRUNCATE_CHARS} chars")
3601
+ if FLAG_FILE:
3602
+ print(f"* Flag file:\t\t\t{FLAG_FILE}")
3463
3603
  print(f"* Configuration file:\t\t{cfg_path}")
3464
3604
  print(f"* Dotenv file:\t\t\t{env_path or 'None'}\n")
3465
3605
 
@@ -1,7 +0,0 @@
1
- spotify_monitor.py,sha256=-uJet8WI668Ihnqs3uArowPfrYa4MUzB1QPYEL_1EGg,153671
2
- spotify_monitor-2.2.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- spotify_monitor-2.2.1.dist-info/METADATA,sha256=q0IT4d33nTghRZ4boNvp0KxUgmD17-URY6bQMTgPAI8,22613
4
- spotify_monitor-2.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- spotify_monitor-2.2.1.dist-info/entry_points.txt,sha256=8HzePfUcCSXrYaXOwLbNNYO8GJcnhgCSl4wcDNECht8,57
6
- spotify_monitor-2.2.1.dist-info/top_level.txt,sha256=EP6IPD4vHT12rLM5b_jo2i3nrfOuwk3ehhr2gWdQx9Y,16
7
- spotify_monitor-2.2.1.dist-info/RECORD,,