spotify-monitor 2.0rc1__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 ADDED
@@ -0,0 +1,2478 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
4
+ v2.0
5
+
6
+ Tool implementing real-time tracking of Spotify friends music activity:
7
+ https://github.com/misiektoja/spotify_monitor/
8
+
9
+ Python pip3 requirements:
10
+
11
+ requests
12
+ python-dateutil
13
+ urllib3
14
+ pyotp
15
+ python-dotenv (optional)
16
+ """
17
+
18
+ VERSION = "2.0"
19
+
20
+ # ---------------------------
21
+ # CONFIGURATION SECTION START
22
+ # ---------------------------
23
+
24
+ CONFIG_BLOCK = """
25
+ # Log in to Spotify web client (https://open.spotify.com/) and retrieve your sp_dc cookie
26
+ # Use your web browser's dev console or "Cookie-Editor" by cgagnier to extract it easily: https://cookie-editor.com/
27
+ # The sp_dc cookie is typically valid for up to 2 weeks
28
+ #
29
+ # Provide the SP_DC_COOKIE secret using one of the following methods:
30
+ # - Pass it at runtime with -u / --spotify-dc-cookie
31
+ # - Set it as an environment variable (e.g. export SP_DC_COOKIE=...)
32
+ # - Add it to ".env" file (SP_DC_COOKIE=...) for persistent use
33
+ # Fallback:
34
+ # - Hard-code it in the code or config file
35
+ SP_DC_COOKIE = "your_sp_dc_cookie_value"
36
+
37
+ # SMTP settings for sending email notifications
38
+ # If left as-is, no notifications will be sent
39
+ #
40
+ # Provide the SMTP_PASSWORD secret using one of the following methods:
41
+ # - Set it as an environment variable (e.g. export SMTP_PASSWORD=...)
42
+ # - Add it to ".env" file (SMTP_PASSWORD=...) for persistent use
43
+ # Fallback:
44
+ # - Hard-code it in the code or config file
45
+ SMTP_HOST = "your_smtp_server_ssl"
46
+ SMTP_PORT = 587
47
+ SMTP_USER = "your_smtp_user"
48
+ SMTP_PASSWORD = "your_smtp_password"
49
+ SMTP_SSL = True
50
+ SENDER_EMAIL = "your_sender_email"
51
+ RECEIVER_EMAIL = "your_receiver_email"
52
+
53
+ # Whether to send an email when user becomes active
54
+ # Can also be enabled via the -a flag
55
+ ACTIVE_NOTIFICATION = False
56
+
57
+ # Whether to send an email when user goes inactive
58
+ # Can also be enabled via the -i flag
59
+ INACTIVE_NOTIFICATION = False
60
+
61
+ # Whether to send an email when a monitored track/playlist/album plays
62
+ # Can also be enabled via the -t flag
63
+ TRACK_NOTIFICATION = False
64
+
65
+ # Whether to send an email on every song change
66
+ # Can also be enabled via the -j flag
67
+ SONG_NOTIFICATION = False
68
+
69
+ # Whether to send an email when user plays a song on loop
70
+ # Triggered if the same song is played more than SONG_ON_LOOP_VALUE times
71
+ # Can also be enabled via the -x flag
72
+ SONG_ON_LOOP_NOTIFICATION = False
73
+
74
+ # Whether to send an email on errors
75
+ # Can also be disabled via the -e flag
76
+ ERROR_NOTIFICATION = True
77
+
78
+ # How often to check for user activity; in seconds
79
+ # Can also be set using the -c flag
80
+ SPOTIFY_CHECK_INTERVAL = 30 # 30 seconds
81
+
82
+ # Time to wait before retrying after an error; in seconds
83
+ SPOTIFY_ERROR_INTERVAL = 180 # 3 mins
84
+
85
+ # Time after which a user is considered inactive (based on last activity); in seconds
86
+ # Can also be set using the -o flag
87
+ # Note: If the user listens to songs longer than this value, they may be marked as inactive
88
+ SPOTIFY_INACTIVITY_CHECK = 660 # 11 mins
89
+
90
+ # Interval for checking if a user who disappeared from the list of recently active friends has reappeared; in seconds
91
+ # Can happen due to:
92
+ # - unfollowing the user
93
+ # - Spotify service issues
94
+ # - private session bugs
95
+ # - user inactivity for over a week
96
+ # In such a case, the tool will continuously check for the user's reappearance using the time interval specified below
97
+ # Can also be set using the -m flag
98
+ SPOTIFY_DISAPPEARED_CHECK_INTERVAL = 180 # 3 mins
99
+
100
+ # Whether to auto‑play each listened song in your Spotify client
101
+ # Can also be set using the -g flag
102
+ TRACK_SONGS = False
103
+
104
+ # Method used to play the song listened by the tracked user in local Spotify client under macOS
105
+ # (i.e. when TRACK_SONGS / -g functionality is enabled)
106
+ # Methods:
107
+ # "apple-script" (recommended)
108
+ # "trigger-url"
109
+ SPOTIFY_MACOS_PLAYING_METHOD = "apple-script"
110
+
111
+ # Method used to play the song listened by the tracked user in local Spotify client under Linux OS
112
+ # (i.e. when TRACK_SONGS / -g functionality is enabled)
113
+ # Methods:
114
+ # "dbus-send" (most common one)
115
+ # "qdbus"
116
+ # "trigger-url"
117
+ SPOTIFY_LINUX_PLAYING_METHOD = "dbus-send"
118
+
119
+ # Method used to play the song listened by the tracked user in local Spotify client under Windows OS
120
+ # (if TRACK_SONGS / -g functionality is enabled)
121
+ # Methods:
122
+ # "start-uri" (recommended)
123
+ # "spotify-cmd"
124
+ # "trigger-url"
125
+ SPOTIFY_WINDOWS_PLAYING_METHOD = "start-uri"
126
+
127
+ # Number of consecutive plays of the same song considered to be on loop
128
+ SONG_ON_LOOP_VALUE = 3
129
+
130
+ # Threshold for considering a song as skipped (fraction of duration)
131
+ SKIPPED_SONG_THRESHOLD = 0.55 # song is treated as skipped if played for <= 55% of its total length
132
+
133
+ # Spotify track ID to play when the user goes offline (used when TRACK_SONGS / -g functionality is enabled)
134
+ # Leave empty to simply pause
135
+ # SP_USER_GOT_OFFLINE_TRACK_ID = "5wCjNjnugSUqGDBrmQhn0e"
136
+ SP_USER_GOT_OFFLINE_TRACK_ID = ""
137
+
138
+ # Delay before pausing the above track after the user goes offline; in seconds
139
+ # Set to 0 to keep playing indefinitely until manually paused
140
+ SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE = 5 # 5 seconds
141
+
142
+ # How often to print a "liveness check" message to the output; in seconds
143
+ # Set to 0 to disable
144
+ LIVENESS_CHECK_INTERVAL = 43200 # 12 hours
145
+
146
+ # URL used to verify internet connectivity at startup
147
+ CHECK_INTERNET_URL = 'https://api.spotify.com/v1'
148
+
149
+ # Timeout used when checking initial internet connectivity; in seconds
150
+ CHECK_INTERNET_TIMEOUT = 5
151
+
152
+ # Whether to enable / disable SSL certificate verification while sending https requests
153
+ VERIFY_SSL = True
154
+
155
+ # Threshold for displaying Spotify 50x errors - it is to suppress sporadic issues with Spotify API endpoint
156
+ # Adjust the values according to the SPOTIFY_CHECK_INTERVAL timer
157
+ # If more than 6 Spotify API related errors in 4 minutes, show an alert
158
+ ERROR_500_NUMBER_LIMIT = 6
159
+ ERROR_500_TIME_LIMIT = 240 # 4 min
160
+
161
+ # Threshold for displaying network errors - it is to suppress sporadic issues with internet connectivity
162
+ # Adjust the values according to the SPOTIFY_CHECK_INTERVAL timer
163
+ # If more than 6 network related errors in 4 minutes, show an alert
164
+ ERROR_NETWORK_ISSUES_NUMBER_LIMIT = 6
165
+ ERROR_NETWORK_ISSUES_TIME_LIMIT = 240 # 4 min
166
+
167
+ # CSV file to write every listened track
168
+ # Can also be set using the -b flag
169
+ CSV_FILE = ""
170
+
171
+ # Filename with Spotify tracks/playlists/albums to alert on
172
+ # Can also be set using the -s flag
173
+ MONITOR_LIST_FILE = ""
174
+
175
+ # Location of the optional dotenv file which can keep secrets
176
+ # If not specified it will try to auto-search for .env files
177
+ # To disable auto-search, set this to the literal string "none"
178
+ # Can also be set using the --env-file flag
179
+ DOTENV_FILE = ""
180
+
181
+ # Suffix to append to the output filenames instead of default user URI ID
182
+ # Can also be set using the -y flag
183
+ FILE_SUFFIX = ""
184
+
185
+ # Base name for the log file. Output will be saved to spotify_monitor_<user_uri_id/file_suffix>.log
186
+ # Can include a directory path to specify the location, e.g. ~/some_dir/spotify_monitor
187
+ SP_LOGFILE = "spotify_monitor"
188
+
189
+ # Whether to disable logging to spotify_monitor_<user_uri_id/file_suffix>.log
190
+ # Can also be disabled via the -d flag
191
+ DISABLE_LOGGING = False
192
+
193
+ # Width of horizontal line (─)
194
+ HORIZONTAL_LINE = 113
195
+
196
+ # Whether to clear the terminal screen after starting the tool
197
+ CLEAR_SCREEN = True
198
+
199
+ # Value added/subtracted via signal handlers to adjust inactivity timeout (SPOTIFY_INACTIVITY_CHECK); in seconds
200
+ SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE = 30 # 30 seconds
201
+
202
+ # Maximum number of attempts to get a valid access token in a single run of the spotify_get_access_token() function
203
+ TOKEN_MAX_RETRIES = 10
204
+
205
+ # Interval between access token retry attempts; in seconds
206
+ TOKEN_RETRY_TIMEOUT = 0.5 # 0.5 second
207
+ """
208
+
209
+ # -------------------------
210
+ # CONFIGURATION SECTION END
211
+ # -------------------------
212
+
213
+ # Default dummy values so linters shut up
214
+ # Do not change values below - modify them in the configuration section or config file instead
215
+ SP_DC_COOKIE = ""
216
+ SMTP_HOST = ""
217
+ SMTP_PORT = 0
218
+ SMTP_USER = ""
219
+ SMTP_PASSWORD = ""
220
+ SMTP_SSL = False
221
+ SENDER_EMAIL = ""
222
+ RECEIVER_EMAIL = ""
223
+ ACTIVE_NOTIFICATION = False
224
+ INACTIVE_NOTIFICATION = False
225
+ TRACK_NOTIFICATION = False
226
+ SONG_NOTIFICATION = False
227
+ SONG_ON_LOOP_NOTIFICATION = False
228
+ ERROR_NOTIFICATION = False
229
+ SPOTIFY_CHECK_INTERVAL = 0
230
+ SPOTIFY_ERROR_INTERVAL = 0
231
+ SPOTIFY_INACTIVITY_CHECK = 0
232
+ SPOTIFY_DISAPPEARED_CHECK_INTERVAL = 0
233
+ TRACK_SONGS = False
234
+ SPOTIFY_MACOS_PLAYING_METHOD = ""
235
+ SPOTIFY_LINUX_PLAYING_METHOD = ""
236
+ SPOTIFY_WINDOWS_PLAYING_METHOD = ""
237
+ SONG_ON_LOOP_VALUE = 0
238
+ SKIPPED_SONG_THRESHOLD = 0
239
+ SP_USER_GOT_OFFLINE_TRACK_ID = ""
240
+ SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE = 0
241
+ LIVENESS_CHECK_INTERVAL = 0
242
+ CHECK_INTERNET_URL = ""
243
+ CHECK_INTERNET_TIMEOUT = 0
244
+ VERIFY_SSL = False
245
+ ERROR_500_NUMBER_LIMIT = 0
246
+ ERROR_500_TIME_LIMIT = 0
247
+ ERROR_NETWORK_ISSUES_NUMBER_LIMIT = 0
248
+ ERROR_NETWORK_ISSUES_TIME_LIMIT = 0
249
+ CSV_FILE = ""
250
+ MONITOR_LIST_FILE = ""
251
+ DOTENV_FILE = ""
252
+ FILE_SUFFIX = ""
253
+ SP_LOGFILE = ""
254
+ DISABLE_LOGGING = False
255
+ HORIZONTAL_LINE = 0
256
+ CLEAR_SCREEN = False
257
+ SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE = 0
258
+ TOKEN_MAX_RETRIES = 0
259
+ TOKEN_RETRY_TIMEOUT = 0.0
260
+
261
+ exec(CONFIG_BLOCK, globals())
262
+
263
+ # Default name for the optional config file
264
+ DEFAULT_CONFIG_FILENAME = "spotify_monitor.conf"
265
+
266
+ # List of secret keys to load from env/config
267
+ SECRET_KEYS = ("SP_DC_COOKIE", "SMTP_PASSWORD")
268
+
269
+ # Strings removed from track names for generating proper Genius search URLs
270
+ re_search_str = r'remaster|extended|original mix|remix|original soundtrack|radio( |-)edit|\(feat\.|( \(.*version\))|( - .*version)'
271
+ re_replace_str = r'( - (\d*)( )*remaster$)|( - (\d*)( )*remastered( version)*( \d*)*.*$)|( \((\d*)( )*remaster\)$)|( - (\d+) - remaster$)|( - extended$)|( - extended mix$)|( - (.*); extended mix$)|( - extended version$)|( - (.*) remix$)|( - remix$)|( - remixed by .*$)|( - original mix$)|( - .*original soundtrack$)|( - .*radio( |-)edit$)|( \(feat\. .*\)$)|( \(\d+.*Remaster.*\)$)|( \(.*Version\))|( - .*version)'
272
+
273
+ # Default value for network-related timeouts in functions
274
+ FUNCTION_TIMEOUT = 15
275
+
276
+ # Variables for caching functionality of the Spotify access token to avoid unnecessary refreshing
277
+ SP_CACHED_ACCESS_TOKEN = None
278
+ SP_TOKEN_EXPIRES_AT = 0
279
+ SP_CACHED_CLIENT_ID = ""
280
+ SP_CACHED_USER_AGENT = ""
281
+
282
+ # URL of the Spotify Web Player endpoint to get access token
283
+ TOKEN_URL = "https://open.spotify.com/get_access_token"
284
+
285
+ # URL of the endpoint to get server time needed to create TOTP object
286
+ SERVER_TIME_URL = "https://open.spotify.com/server-time"
287
+
288
+ # Default value for alarm signal handler timeout; in seconds
289
+ ALARM_TIMEOUT = int((TOKEN_MAX_RETRIES * TOKEN_RETRY_TIMEOUT) + 5)
290
+ ALARM_RETRY = 10
291
+
292
+ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / SPOTIFY_CHECK_INTERVAL
293
+
294
+ stdout_bck = None
295
+ csvfieldnames = ['Date', 'Artist', 'Track', 'Playlist', 'Album', 'Last activity']
296
+
297
+ CLI_CONFIG_PATH = None
298
+
299
+ # to solve the issue: 'SyntaxError: f-string expression part cannot include a backslash'
300
+ nl_ch = "\n"
301
+
302
+
303
+ import sys
304
+
305
+ if sys.version_info < (3, 6):
306
+ print("* Error: Python version 3.6 or higher required !")
307
+ sys.exit(1)
308
+
309
+ import time
310
+ from time import time_ns
311
+ import string
312
+ import json
313
+ import os
314
+ from datetime import datetime
315
+ from dateutil import relativedelta
316
+ import calendar
317
+ import requests as req
318
+ import signal
319
+ import smtplib
320
+ import ssl
321
+ from email.header import Header
322
+ from email.mime.multipart import MIMEMultipart
323
+ from email.mime.text import MIMEText
324
+ import argparse
325
+ import csv
326
+ from urllib.parse import quote_plus, quote
327
+ import subprocess
328
+ import platform
329
+ import re
330
+ import ipaddress
331
+ from html import escape
332
+ try:
333
+ import pyotp
334
+ except ModuleNotFoundError:
335
+ raise SystemExit("Error: Couldn't find the pyotp library !\n\nTo install it, run:\n pip3 install pyotp\n\nOnce installed, re-run this tool")
336
+ import base64
337
+ import random
338
+ import shutil
339
+ from pathlib import Path
340
+
341
+ import urllib3
342
+ if not VERIFY_SSL:
343
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
344
+
345
+ SESSION = req.Session()
346
+
347
+ from requests.adapters import HTTPAdapter
348
+ from urllib3.util.retry import Retry
349
+
350
+ retry = Retry(
351
+ total=5,
352
+ connect=3,
353
+ read=3,
354
+ backoff_factor=1,
355
+ status_forcelist=[429, 500, 502, 503, 504],
356
+ allowed_methods=["GET", "HEAD", "OPTIONS"],
357
+ raise_on_status=False
358
+ )
359
+
360
+ adapter = HTTPAdapter(max_retries=retry, pool_connections=100, pool_maxsize=100)
361
+ SESSION.mount("https://", adapter)
362
+ SESSION.mount("http://", adapter)
363
+
364
+
365
+ # Logger class to output messages to stdout and log file
366
+ class Logger(object):
367
+ def __init__(self, filename):
368
+ self.terminal = sys.stdout
369
+ self.logfile = open(filename, "a", buffering=1, encoding="utf-8")
370
+
371
+ def write(self, message):
372
+ self.terminal.write(message)
373
+ self.logfile.write(message)
374
+ self.terminal.flush()
375
+ self.logfile.flush()
376
+
377
+ def flush(self):
378
+ pass
379
+
380
+
381
+ # Class used to generate timeout exceptions
382
+ class TimeoutException(Exception):
383
+ pass
384
+
385
+
386
+ # Signal handler for SIGALRM when the operation times out
387
+ def timeout_handler(sig, frame):
388
+ raise TimeoutException
389
+
390
+
391
+ # Signal handler when user presses Ctrl+C
392
+ def signal_handler(sig, frame):
393
+ sys.stdout = stdout_bck
394
+ print('\n* You pressed Ctrl+C, tool is terminated.')
395
+ sys.exit(0)
396
+
397
+
398
+ # Checks internet connectivity
399
+ def check_internet(url=CHECK_INTERNET_URL, timeout=CHECK_INTERNET_TIMEOUT, verify=VERIFY_SSL):
400
+ try:
401
+ _ = req.get(url, timeout=timeout, verify=verify)
402
+ return True
403
+ except req.RequestException as e:
404
+ print(f"* No connectivity, please check your network:\n\n{e}")
405
+ return False
406
+
407
+
408
+ # Clears the terminal screen
409
+ def clear_screen(enabled=True):
410
+ if not enabled:
411
+ return
412
+ try:
413
+ if platform.system() == 'Windows':
414
+ os.system('cls')
415
+ else:
416
+ os.system('clear')
417
+ except Exception:
418
+ print("* Cannot clear the screen contents")
419
+
420
+
421
+ # Converts absolute value of seconds to human readable format
422
+ def display_time(seconds, granularity=2):
423
+ intervals = (
424
+ ('years', 31556952), # approximation
425
+ ('months', 2629746), # approximation
426
+ ('weeks', 604800), # 60 * 60 * 24 * 7
427
+ ('days', 86400), # 60 * 60 * 24
428
+ ('hours', 3600), # 60 * 60
429
+ ('minutes', 60),
430
+ ('seconds', 1),
431
+ )
432
+ result = []
433
+
434
+ if seconds > 0:
435
+ for name, count in intervals:
436
+ value = seconds // count
437
+ if value:
438
+ seconds -= value * count
439
+ if value == 1:
440
+ name = name.rstrip('s')
441
+ result.append(f"{value} {name}")
442
+ return ', '.join(result[:granularity])
443
+ else:
444
+ return '0 seconds'
445
+
446
+
447
+ # Calculates time span between two timestamps, accepts timestamp integers, floats and datetime objects
448
+ def calculate_timespan(timestamp1, timestamp2, show_weeks=True, show_hours=True, show_minutes=True, show_seconds=True, granularity=3):
449
+ result = []
450
+ intervals = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds']
451
+ ts1 = timestamp1
452
+ ts2 = timestamp2
453
+
454
+ if type(timestamp1) is int:
455
+ dt1 = datetime.fromtimestamp(int(ts1))
456
+ elif type(timestamp1) is float:
457
+ ts1 = int(round(ts1))
458
+ dt1 = datetime.fromtimestamp(ts1)
459
+ elif type(timestamp1) is datetime:
460
+ dt1 = timestamp1
461
+ ts1 = int(round(dt1.timestamp()))
462
+ else:
463
+ return ""
464
+
465
+ if type(timestamp2) is int:
466
+ dt2 = datetime.fromtimestamp(int(ts2))
467
+ elif type(timestamp2) is float:
468
+ ts2 = int(round(ts2))
469
+ dt2 = datetime.fromtimestamp(ts2)
470
+ elif type(timestamp2) is datetime:
471
+ dt2 = timestamp2
472
+ ts2 = int(round(dt2.timestamp()))
473
+ else:
474
+ return ""
475
+
476
+ if ts1 >= ts2:
477
+ ts_diff = ts1 - ts2
478
+ else:
479
+ ts_diff = ts2 - ts1
480
+ dt1, dt2 = dt2, dt1
481
+
482
+ if ts_diff > 0:
483
+ date_diff = relativedelta.relativedelta(dt1, dt2)
484
+ years = date_diff.years
485
+ months = date_diff.months
486
+ weeks = date_diff.weeks
487
+ if not show_weeks:
488
+ weeks = 0
489
+ days = date_diff.days
490
+ if weeks > 0:
491
+ days = days - (weeks * 7)
492
+ hours = date_diff.hours
493
+ if (not show_hours and ts_diff > 86400):
494
+ hours = 0
495
+ minutes = date_diff.minutes
496
+ if (not show_minutes and ts_diff > 3600):
497
+ minutes = 0
498
+ seconds = date_diff.seconds
499
+ if (not show_seconds and ts_diff > 60):
500
+ seconds = 0
501
+ date_list = [years, months, weeks, days, hours, minutes, seconds]
502
+
503
+ for index, interval in enumerate(date_list):
504
+ if interval > 0:
505
+ name = intervals[index]
506
+ if interval == 1:
507
+ name = name.rstrip('s')
508
+ result.append(f"{interval} {name}")
509
+ return ', '.join(result[:granularity])
510
+ else:
511
+ return '0 seconds'
512
+
513
+
514
+ # Sends email notification
515
+ def send_email(subject, body, body_html, use_ssl, smtp_timeout=15):
516
+ fqdn_re = re.compile(r'(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}\.?$)')
517
+ email_re = re.compile(r'[^@]+@[^@]+\.[^@]+')
518
+
519
+ try:
520
+ ipaddress.ip_address(str(SMTP_HOST))
521
+ except ValueError:
522
+ if not fqdn_re.search(str(SMTP_HOST)):
523
+ print("Error sending email - SMTP settings are incorrect (invalid IP address/FQDN in SMTP_HOST)")
524
+ return 1
525
+
526
+ try:
527
+ port = int(SMTP_PORT)
528
+ if not (1 <= port <= 65535):
529
+ raise ValueError
530
+ except ValueError:
531
+ print("Error sending email - SMTP settings are incorrect (invalid port number in SMTP_PORT)")
532
+ return 1
533
+
534
+ if not email_re.search(str(SENDER_EMAIL)) or not email_re.search(str(RECEIVER_EMAIL)):
535
+ print("Error sending email - SMTP settings are incorrect (invalid email in SENDER_EMAIL or RECEIVER_EMAIL)")
536
+ return 1
537
+
538
+ if not SMTP_USER or not isinstance(SMTP_USER, str) or SMTP_USER == "your_smtp_user" or not SMTP_PASSWORD or not isinstance(SMTP_PASSWORD, str) or SMTP_PASSWORD == "your_smtp_password":
539
+ print("Error sending email - SMTP settings are incorrect (check SMTP_USER & SMTP_PASSWORD variables)")
540
+ return 1
541
+
542
+ if not subject or not isinstance(subject, str):
543
+ print("Error sending email - SMTP settings are incorrect (subject is not a string or is empty)")
544
+ return 1
545
+
546
+ if not body and not body_html:
547
+ print("Error sending email - SMTP settings are incorrect (body and body_html cannot be empty at the same time)")
548
+ return 1
549
+
550
+ try:
551
+ if use_ssl:
552
+ ssl_context = ssl.create_default_context()
553
+ smtpObj = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=smtp_timeout)
554
+ smtpObj.starttls(context=ssl_context)
555
+ else:
556
+ smtpObj = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=smtp_timeout)
557
+ smtpObj.login(SMTP_USER, SMTP_PASSWORD)
558
+ email_msg = MIMEMultipart('alternative')
559
+ email_msg["From"] = SENDER_EMAIL
560
+ email_msg["To"] = RECEIVER_EMAIL
561
+ email_msg["Subject"] = str(Header(subject, 'utf-8'))
562
+
563
+ if body:
564
+ part1 = MIMEText(body, 'plain')
565
+ part1 = MIMEText(body.encode('utf-8'), 'plain', _charset='utf-8')
566
+ email_msg.attach(part1)
567
+
568
+ if body_html:
569
+ part2 = MIMEText(body_html, 'html')
570
+ part2 = MIMEText(body_html.encode('utf-8'), 'html', _charset='utf-8')
571
+ email_msg.attach(part2)
572
+
573
+ smtpObj.sendmail(SENDER_EMAIL, RECEIVER_EMAIL, email_msg.as_string())
574
+ smtpObj.quit()
575
+ except Exception as e:
576
+ print(f"Error sending email: {e}")
577
+ return 1
578
+ return 0
579
+
580
+
581
+ # Initializes the CSV file
582
+ def init_csv_file(csv_file_name):
583
+ try:
584
+ if not os.path.isfile(csv_file_name) or os.path.getsize(csv_file_name) == 0:
585
+ with open(csv_file_name, 'a', newline='', buffering=1, encoding="utf-8") as f:
586
+ writer = csv.DictWriter(f, fieldnames=csvfieldnames, quoting=csv.QUOTE_NONNUMERIC)
587
+ writer.writeheader()
588
+ except Exception as e:
589
+ raise RuntimeError(f"Could not initialize CSV file '{csv_file_name}': {e}")
590
+
591
+
592
+ # Writes CSV entry
593
+ def write_csv_entry(csv_file_name, timestamp, artist, track, playlist, album, last_activity_ts):
594
+ try:
595
+
596
+ with open(csv_file_name, 'a', newline='', buffering=1, encoding="utf-8") as csv_file:
597
+ csvwriter = csv.DictWriter(csv_file, fieldnames=csvfieldnames, quoting=csv.QUOTE_NONNUMERIC)
598
+ csvwriter.writerow({'Date': timestamp, 'Artist': artist, 'Track': track, 'Playlist': playlist, 'Album': album, 'Last activity': last_activity_ts})
599
+
600
+ except Exception as e:
601
+ raise RuntimeError(f"Failed to write to CSV file '{csv_file_name}': {e}")
602
+
603
+
604
+ # Returns the current date/time in human readable format; eg. Sun 21 Apr 2024, 15:08:45
605
+ def get_cur_ts(ts_str=""):
606
+ return (f'{ts_str}{calendar.day_abbr[(datetime.fromtimestamp(int(time.time()))).weekday()]}, {datetime.fromtimestamp(int(time.time())).strftime("%d %b %Y, %H:%M:%S")}')
607
+
608
+
609
+ # Prints the current date/time in human readable format with separator; eg. Sun 21 Apr 2024, 15:08:45
610
+ def print_cur_ts(ts_str=""):
611
+ print(get_cur_ts(str(ts_str)))
612
+ print("─" * HORIZONTAL_LINE)
613
+
614
+
615
+ # Returns the timestamp/datetime object in human readable format (long version); eg. Sun 21 Apr 2024, 15:08:45
616
+ def get_date_from_ts(ts):
617
+ if type(ts) is datetime:
618
+ ts_new = int(round(ts.timestamp()))
619
+ elif type(ts) is int:
620
+ ts_new = ts
621
+ elif type(ts) is float:
622
+ ts_new = int(round(ts))
623
+ else:
624
+ return ""
625
+
626
+ return (f'{calendar.day_abbr[(datetime.fromtimestamp(ts_new)).weekday()]} {datetime.fromtimestamp(ts_new).strftime("%d %b %Y, %H:%M:%S")}')
627
+
628
+
629
+ # Returns the timestamp/datetime object in human readable format (short version); eg.
630
+ # Sun 21 Apr 15:08
631
+ # Sun 21 Apr 24, 15:08 (if show_year == True and current year is different)
632
+ # Sun 21 Apr (if show_hour == False)
633
+ def get_short_date_from_ts(ts, show_year=False, show_hour=True):
634
+ if type(ts) is datetime:
635
+ ts_new = int(round(ts.timestamp()))
636
+ elif type(ts) is int:
637
+ ts_new = ts
638
+ elif type(ts) is float:
639
+ ts_new = int(round(ts))
640
+ else:
641
+ return ""
642
+
643
+ if show_hour:
644
+ hour_strftime = " %H:%M"
645
+ else:
646
+ hour_strftime = ""
647
+
648
+ if show_year and int(datetime.fromtimestamp(ts_new).strftime("%Y")) != int(datetime.now().strftime("%Y")):
649
+ if show_hour:
650
+ hour_prefix = ","
651
+ else:
652
+ hour_prefix = ""
653
+ return (f'{calendar.day_abbr[(datetime.fromtimestamp(ts_new)).weekday()]} {datetime.fromtimestamp(ts_new).strftime(f"%d %b %y{hour_prefix}{hour_strftime}")}')
654
+ else:
655
+ return (f'{calendar.day_abbr[(datetime.fromtimestamp(ts_new)).weekday()]} {datetime.fromtimestamp(ts_new).strftime(f"%d %b{hour_strftime}")}')
656
+
657
+
658
+ # Returns the timestamp/datetime object in human readable format (only hour, minutes and optionally seconds): eg. 15:08:12
659
+ def get_hour_min_from_ts(ts, show_seconds=False):
660
+ if type(ts) is datetime:
661
+ ts_new = int(round(ts.timestamp()))
662
+ elif type(ts) is int:
663
+ ts_new = ts
664
+ elif type(ts) is float:
665
+ ts_new = int(round(ts))
666
+ else:
667
+ return ""
668
+
669
+ if show_seconds:
670
+ out_strf = "%H:%M:%S"
671
+ else:
672
+ out_strf = "%H:%M"
673
+ return (str(datetime.fromtimestamp(ts_new).strftime(out_strf)))
674
+
675
+
676
+ # Returns the range between two timestamps/datetime objects; eg. Sun 21 Apr 14:09 - 14:15
677
+ def get_range_of_dates_from_tss(ts1, ts2, between_sep=" - ", short=False):
678
+ if type(ts1) is datetime:
679
+ ts1_new = int(round(ts1.timestamp()))
680
+ elif type(ts1) is int:
681
+ ts1_new = ts1
682
+ elif type(ts1) is float:
683
+ ts1_new = int(round(ts1))
684
+ else:
685
+ return ""
686
+
687
+ if type(ts2) is datetime:
688
+ ts2_new = int(round(ts2.timestamp()))
689
+ elif type(ts2) is int:
690
+ ts2_new = ts2
691
+ elif type(ts2) is float:
692
+ ts2_new = int(round(ts2))
693
+ else:
694
+ return ""
695
+
696
+ ts1_strf = datetime.fromtimestamp(ts1_new).strftime("%Y%m%d")
697
+ ts2_strf = datetime.fromtimestamp(ts2_new).strftime("%Y%m%d")
698
+
699
+ if ts1_strf == ts2_strf:
700
+ if short:
701
+ out_str = f"{get_short_date_from_ts(ts1_new)}{between_sep}{get_hour_min_from_ts(ts2_new)}"
702
+ else:
703
+ out_str = f"{get_date_from_ts(ts1_new)}{between_sep}{get_hour_min_from_ts(ts2_new, show_seconds=True)}"
704
+ else:
705
+ if short:
706
+ out_str = f"{get_short_date_from_ts(ts1_new)}{between_sep}{get_short_date_from_ts(ts2_new)}"
707
+ else:
708
+ out_str = f"{get_date_from_ts(ts1_new)}{between_sep}{get_date_from_ts(ts2_new)}"
709
+ return (str(out_str))
710
+
711
+
712
+ # Signal handler for SIGUSR1 allowing to switch active/inactive email notifications
713
+ def toggle_active_inactive_notifications_signal_handler(sig, frame):
714
+ global ACTIVE_NOTIFICATION
715
+ global INACTIVE_NOTIFICATION
716
+ ACTIVE_NOTIFICATION = not ACTIVE_NOTIFICATION
717
+ INACTIVE_NOTIFICATION = not INACTIVE_NOTIFICATION
718
+ sig_name = signal.Signals(sig).name
719
+ print(f"* Signal {sig_name} received")
720
+ print(f"* Email notifications: [active = {ACTIVE_NOTIFICATION}] [inactive = {INACTIVE_NOTIFICATION}]")
721
+ print_cur_ts("Timestamp:\t\t\t")
722
+
723
+
724
+ # Signal handler for SIGUSR2 allowing to switch every song email notifications
725
+ def toggle_song_notifications_signal_handler(sig, frame):
726
+ global SONG_NOTIFICATION
727
+ SONG_NOTIFICATION = not SONG_NOTIFICATION
728
+ sig_name = signal.Signals(sig).name
729
+ print(f"* Signal {sig_name} received")
730
+ print(f"* Email notifications: [every song = {SONG_NOTIFICATION}]")
731
+ print_cur_ts("Timestamp:\t\t\t")
732
+
733
+
734
+ # Signal handler for SIGCONT allowing to switch tracked songs email notifications
735
+ def toggle_track_notifications_signal_handler(sig, frame):
736
+ global TRACK_NOTIFICATION
737
+ TRACK_NOTIFICATION = not TRACK_NOTIFICATION
738
+ sig_name = signal.Signals(sig).name
739
+ print(f"* Signal {sig_name} received")
740
+ print(f"* Email notifications: [tracked = {TRACK_NOTIFICATION}]")
741
+ print_cur_ts("Timestamp:\t\t\t")
742
+
743
+
744
+ # Signal handler for SIGPIPE allowing to switch songs on loop email notifications
745
+ def toggle_songs_on_loop_notifications_signal_handler(sig, frame):
746
+ global SONG_ON_LOOP_NOTIFICATION
747
+ SONG_ON_LOOP_NOTIFICATION = not SONG_ON_LOOP_NOTIFICATION
748
+ sig_name = signal.Signals(sig).name
749
+ print(f"* Signal {sig_name} received")
750
+ print(f"* Email notifications: [songs on loop = {SONG_ON_LOOP_NOTIFICATION}]")
751
+ print_cur_ts("Timestamp:\t\t\t")
752
+
753
+
754
+ # Signal handler for SIGTRAP allowing to increase inactivity check timer by SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE seconds
755
+ def increase_inactivity_check_signal_handler(sig, frame):
756
+ global SPOTIFY_INACTIVITY_CHECK
757
+ SPOTIFY_INACTIVITY_CHECK = SPOTIFY_INACTIVITY_CHECK + SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE
758
+ sig_name = signal.Signals(sig).name
759
+ print(f"* Signal {sig_name} received")
760
+ print(f"* Spotify timers: [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]")
761
+ print_cur_ts("Timestamp:\t\t\t")
762
+
763
+
764
+ # Signal handler for SIGABRT allowing to decrease inactivity check timer by SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE seconds
765
+ def decrease_inactivity_check_signal_handler(sig, frame):
766
+ global SPOTIFY_INACTIVITY_CHECK
767
+ if SPOTIFY_INACTIVITY_CHECK - SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE > 0:
768
+ SPOTIFY_INACTIVITY_CHECK = SPOTIFY_INACTIVITY_CHECK - SPOTIFY_INACTIVITY_CHECK_SIGNAL_VALUE
769
+ sig_name = signal.Signals(sig).name
770
+ print(f"* Signal {sig_name} received")
771
+ print(f"* Spotify timers: [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]")
772
+ print_cur_ts("Timestamp:\t\t\t")
773
+
774
+
775
+ # Signal handler for SIGHUP allowing to reload secrets from .env
776
+ def reload_secrets_signal_handler(sig, frame):
777
+ sig_name = signal.Signals(sig).name
778
+ print(f"* Signal {sig_name} received")
779
+
780
+ # disable autoscan if DOTENV_FILE set to none
781
+ if DOTENV_FILE and DOTENV_FILE.lower() == 'none':
782
+ env_path = None
783
+ else:
784
+ # reload .env if python-dotenv is installed
785
+ try:
786
+ from dotenv import load_dotenv, find_dotenv
787
+ if DOTENV_FILE:
788
+ env_path = DOTENV_FILE
789
+ else:
790
+ env_path = find_dotenv()
791
+ if env_path:
792
+ load_dotenv(env_path, override=True)
793
+ else:
794
+ print("* No .env file found, skipping env-var reload")
795
+ except ImportError:
796
+ env_path = None
797
+ print("* python-dotenv not installed, skipping env-var reload")
798
+
799
+ if env_path:
800
+ for secret in SECRET_KEYS:
801
+ old_val = globals().get(secret)
802
+ val = os.getenv(secret)
803
+ if val is not None and val != old_val:
804
+ globals()[secret] = val
805
+ print(f"* Reloaded {secret} from {env_path}")
806
+
807
+ print_cur_ts("Timestamp:\t\t\t")
808
+
809
+
810
+ # Prepares Apple & Genius search URLs for specified track
811
+ def get_apple_genius_search_urls(artist, track):
812
+ genius_search_string = f"{artist} {track}"
813
+ youtube_music_search_string = quote_plus(f"{artist} {track}")
814
+ if re.search(re_search_str, genius_search_string, re.IGNORECASE):
815
+ genius_search_string = re.sub(re_replace_str, '', genius_search_string, flags=re.IGNORECASE)
816
+ apple_search_string = quote(f"{artist} {track}")
817
+ apple_search_url = f"https://music.apple.com/pl/search?term={apple_search_string}"
818
+ genius_search_url = f"https://genius.com/search?q={quote_plus(genius_search_string)}"
819
+ youtube_music_search_url = f"https://music.youtube.com/search?q={youtube_music_search_string}"
820
+ return apple_search_url, genius_search_url, youtube_music_search_url
821
+
822
+
823
+ # Returns random user agent string
824
+ def get_random_user_agent() -> str:
825
+ browser = random.choice(['chrome', 'firefox', 'edge', 'safari'])
826
+
827
+ if browser == 'chrome':
828
+ os_choice = random.choice(['mac', 'windows'])
829
+ if os_choice == 'mac':
830
+ return (
831
+ f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{random.randrange(11, 15)}_{random.randrange(4, 9)}) "
832
+ f"AppleWebKit/{random.randrange(530, 537)}.{random.randrange(30, 37)} (KHTML, like Gecko) "
833
+ f"Chrome/{random.randrange(80, 105)}.0.{random.randrange(3000, 4500)}.{random.randrange(60, 125)} "
834
+ f"Safari/{random.randrange(530, 537)}.{random.randrange(30, 36)}"
835
+ )
836
+ else:
837
+ chrome_version = random.randint(80, 105)
838
+ build = random.randint(3000, 4500)
839
+ patch = random.randint(60, 125)
840
+ return (
841
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
842
+ f"AppleWebKit/537.36 (KHTML, like Gecko) "
843
+ f"Chrome/{chrome_version}.0.{build}.{patch} Safari/537.36"
844
+ )
845
+
846
+ elif browser == 'firefox':
847
+ os_choice = random.choice(['windows', 'mac', 'linux'])
848
+ version = random.randint(90, 110)
849
+ if os_choice == 'windows':
850
+ return (
851
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{version}.0) "
852
+ f"Gecko/20100101 Firefox/{version}.0"
853
+ )
854
+ elif os_choice == 'mac':
855
+ return (
856
+ f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{random.randrange(11, 15)}_{random.randrange(0, 10)}; rv:{version}.0) "
857
+ f"Gecko/20100101 Firefox/{version}.0"
858
+ )
859
+ else:
860
+ return (
861
+ f"Mozilla/5.0 (X11; Linux x86_64; rv:{version}.0) "
862
+ f"Gecko/20100101 Firefox/{version}.0"
863
+ )
864
+
865
+ elif browser == 'edge':
866
+ os_choice = random.choice(['windows', 'mac'])
867
+ chrome_version = random.randint(80, 105)
868
+ build = random.randint(3000, 4500)
869
+ patch = random.randint(60, 125)
870
+ version_str = f"{chrome_version}.0.{build}.{patch}"
871
+ if os_choice == 'windows':
872
+ return (
873
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
874
+ f"AppleWebKit/537.36 (KHTML, like Gecko) "
875
+ f"Chrome/{version_str} Safari/537.36 Edg/{version_str}"
876
+ )
877
+ else:
878
+ return (
879
+ f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{random.randrange(11, 15)}_{random.randrange(0, 10)}) "
880
+ f"AppleWebKit/605.1.15 (KHTML, like Gecko) "
881
+ f"Version/{random.randint(13, 16)}.0 Safari/605.1.15 Edg/{version_str}"
882
+ )
883
+
884
+ elif browser == 'safari':
885
+ os_choice = 'mac'
886
+ if os_choice == 'mac':
887
+ mac_major = random.randrange(11, 16)
888
+ mac_minor = random.randrange(0, 10)
889
+ webkit_major = random.randint(600, 610)
890
+ webkit_minor = random.randint(1, 20)
891
+ webkit_patch = random.randint(1, 20)
892
+ safari_version = random.randint(13, 16)
893
+ return (
894
+ f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{mac_major}_{mac_minor}) "
895
+ f"AppleWebKit/{webkit_major}.{webkit_minor}.{webkit_patch} (KHTML, like Gecko) "
896
+ f"Version/{safari_version}.0 Safari/{webkit_major}.{webkit_minor}.{webkit_patch}"
897
+ )
898
+ else:
899
+ return ""
900
+ else:
901
+ return ""
902
+
903
+
904
+ # Removes spaces from a hex string and converts it into a corresponding bytes object
905
+ def hex_to_bytes(data: str) -> bytes:
906
+ data = data.replace(" ", "")
907
+ return bytes.fromhex(data)
908
+
909
+
910
+ # Creates a TOTP object using a secret derived from transformed cipher bytes
911
+ def generate_totp(ua: str):
912
+ secret_cipher_bytes = [
913
+ 12, 56, 76, 33, 88, 44, 88, 33,
914
+ 78, 78, 11, 66, 22, 22, 55, 69, 54,
915
+ ]
916
+
917
+ transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_cipher_bytes)]
918
+ joined = "".join(str(num) for num in transformed)
919
+ utf8_bytes = joined.encode("utf-8")
920
+ hex_str = "".join(format(b, 'x') for b in utf8_bytes)
921
+ secret_bytes = hex_to_bytes(hex_str)
922
+ secret = base64.b32encode(secret_bytes).decode().rstrip('=')
923
+
924
+ headers = {
925
+ "Host": "open.spotify.com",
926
+ "User-Agent": ua,
927
+ "Accept": "*/*",
928
+ }
929
+
930
+ try:
931
+ if platform.system() != 'Windows':
932
+ signal.signal(signal.SIGALRM, timeout_handler)
933
+ signal.alarm(FUNCTION_TIMEOUT + 2)
934
+ resp = req.get(SERVER_TIME_URL, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
935
+ except (req.RequestException, TimeoutException) as e:
936
+ raise Exception(f"generate_totp() network request timeout after {display_time(FUNCTION_TIMEOUT + 2)}: {e}")
937
+ finally:
938
+ if platform.system() != 'Windows':
939
+ signal.alarm(0)
940
+
941
+ resp.raise_for_status()
942
+
943
+ json_data = resp.json()
944
+ server_time = json_data.get("serverTime")
945
+
946
+ if server_time is None:
947
+ raise Exception("Failed to get server time")
948
+
949
+ totp_obj = pyotp.TOTP(secret, digits=6, interval=30)
950
+
951
+ return totp_obj, server_time
952
+
953
+
954
+ # Sends a lightweight request to check Spotify token validity
955
+ def check_token_validity(token: str, client_id: str, user_agent: str) -> bool:
956
+ url = "https://api.spotify.com/v1/me"
957
+ headers = {
958
+ "Authorization": f"Bearer {token}",
959
+ "Client-Id": client_id,
960
+ "User-Agent": user_agent,
961
+ }
962
+
963
+ if platform.system() != 'Windows':
964
+ signal.signal(signal.SIGALRM, timeout_handler)
965
+ signal.alarm(FUNCTION_TIMEOUT + 2)
966
+ try:
967
+ response = req.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
968
+ valid = response.status_code == 200
969
+ except Exception:
970
+ valid = False
971
+ finally:
972
+ if platform.system() != 'Windows':
973
+ signal.alarm(0)
974
+ return valid
975
+
976
+
977
+ # Retrieves a new Spotify access token using the sp_dc cookie, tries first with mode "transport" and if needed with "init"
978
+ def refresh_token(sp_dc: str) -> dict:
979
+ transport = True
980
+ init = True
981
+ session = req.Session()
982
+ session.cookies.set("sp_dc", sp_dc)
983
+ data: dict = {}
984
+ token = ""
985
+
986
+ ua = get_random_user_agent()
987
+ totp_obj, server_time = generate_totp(ua)
988
+ client_time = int(time_ns() / 1000 / 1000)
989
+ otp_value = totp_obj.at(server_time)
990
+
991
+ params = {
992
+ "reason": "transport",
993
+ "productType": "web-player",
994
+ "totp": otp_value,
995
+ "totpServer": otp_value,
996
+ "totpVer": 5,
997
+ "sTime": server_time,
998
+ "cTime": client_time,
999
+ }
1000
+
1001
+ headers = {
1002
+ "User-Agent": ua,
1003
+ "Accept": "application/json",
1004
+ "Cookie": f"sp_dc={sp_dc}",
1005
+ }
1006
+
1007
+ try:
1008
+ if platform.system() != "Windows":
1009
+ signal.signal(signal.SIGALRM, timeout_handler)
1010
+ signal.alarm(FUNCTION_TIMEOUT + 2)
1011
+
1012
+ response = session.get(TOKEN_URL, params=params, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1013
+ response.raise_for_status()
1014
+ data = response.json()
1015
+ token = data.get("accessToken", "")
1016
+
1017
+ except (req.RequestException, TimeoutException, req.HTTPError, ValueError):
1018
+ transport = False
1019
+ finally:
1020
+ if platform.system() != "Windows":
1021
+ signal.alarm(0)
1022
+
1023
+ if not transport or (transport and not check_token_validity(token, data.get("clientId", ""), ua)):
1024
+ params["reason"] = "init"
1025
+
1026
+ try:
1027
+ if platform.system() != "Windows":
1028
+ signal.signal(signal.SIGALRM, timeout_handler)
1029
+ signal.alarm(FUNCTION_TIMEOUT + 2)
1030
+
1031
+ response = session.get(TOKEN_URL, params=params, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1032
+ response.raise_for_status()
1033
+ data = response.json()
1034
+ token = data.get("accessToken", "")
1035
+
1036
+ except (req.RequestException, TimeoutException, req.HTTPError, ValueError):
1037
+ init = False
1038
+ finally:
1039
+ if platform.system() != "Windows":
1040
+ signal.alarm(0)
1041
+
1042
+ if not init or not data or "accessToken" not in data:
1043
+ raise Exception("refresh_token(): Unsuccessful token request")
1044
+
1045
+ return {
1046
+ "access_token": token,
1047
+ "expires_at": data["accessTokenExpirationTimestampMs"] // 1000,
1048
+ "client_id": data.get("clientId", ""),
1049
+ "user_agent": ua,
1050
+ "length": len(token)
1051
+ }
1052
+
1053
+
1054
+ # Fetches Spotify access token based on provided SP_DC value
1055
+ def spotify_get_access_token(sp_dc: str):
1056
+ global SP_CACHED_ACCESS_TOKEN, SP_TOKEN_EXPIRES_AT, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT
1057
+
1058
+ now = time.time()
1059
+
1060
+ if SP_CACHED_ACCESS_TOKEN and now < SP_TOKEN_EXPIRES_AT and check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT):
1061
+ return SP_CACHED_ACCESS_TOKEN
1062
+
1063
+ max_retries = TOKEN_MAX_RETRIES
1064
+ retry = 0
1065
+
1066
+ while retry < max_retries:
1067
+ token_data = refresh_token(sp_dc)
1068
+ token = token_data["access_token"]
1069
+ client_id = token_data.get("client_id", "")
1070
+ user_agent = token_data.get("user_agent", get_random_user_agent())
1071
+ length = token_data["length"]
1072
+
1073
+ SP_CACHED_ACCESS_TOKEN = token
1074
+ SP_TOKEN_EXPIRES_AT = token_data["expires_at"]
1075
+ SP_CACHED_CLIENT_ID = client_id
1076
+ SP_CACHED_USER_AGENT = user_agent
1077
+
1078
+ if SP_CACHED_ACCESS_TOKEN is None or not check_token_validity(SP_CACHED_ACCESS_TOKEN, SP_CACHED_CLIENT_ID, SP_CACHED_USER_AGENT):
1079
+ retry += 1
1080
+ time.sleep(TOKEN_RETRY_TIMEOUT)
1081
+ else:
1082
+ # print("* Token is valid")
1083
+ break
1084
+
1085
+ # print("Spotify Access Token:", SP_CACHED_ACCESS_TOKEN)
1086
+ # print("Token expires at:", time.ctime(SP_TOKEN_EXPIRES_AT))
1087
+
1088
+ if retry == max_retries:
1089
+ if SP_CACHED_ACCESS_TOKEN is not None:
1090
+ print(f"* Token appears to be still invalid after {max_retries} attempts, returning token anyway")
1091
+ print_cur_ts("Timestamp:\t\t\t")
1092
+ return SP_CACHED_ACCESS_TOKEN
1093
+ else:
1094
+ raise RuntimeError(f"Failed to obtain a valid Spotify access token after {max_retries} attempts")
1095
+
1096
+ return SP_CACHED_ACCESS_TOKEN
1097
+
1098
+
1099
+ # Fetches list of Spotify friends
1100
+ def spotify_get_friends_json(access_token):
1101
+ url = "https://guc-spclient.spotify.com/presence-view/v1/buddylist"
1102
+ headers = {
1103
+ "Authorization": f"Bearer {access_token}",
1104
+ "Client-Id": SP_CACHED_CLIENT_ID,
1105
+ "User-Agent": SP_CACHED_USER_AGENT,
1106
+ }
1107
+
1108
+ response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1109
+ if response.status_code == 401:
1110
+ raise Exception("401 Unauthorized for url: " + url)
1111
+ response.raise_for_status()
1112
+ friends_json = response.json()
1113
+ error_str = friends_json.get("error")
1114
+ if error_str:
1115
+ raise ValueError(error_str)
1116
+
1117
+ return friends_json
1118
+
1119
+
1120
+ # Converts Spotify URI (e.g. spotify:user:username) to URL (e.g. https://open.spotify.com/user/username)
1121
+ def spotify_convert_uri_to_url(uri):
1122
+ # add si parameter so link opens in native Spotify app after clicking
1123
+ si = "?si=1"
1124
+ # si=""
1125
+
1126
+ url = ""
1127
+ if "spotify:user:" in uri:
1128
+ s_id = uri.split(':', 2)[2]
1129
+ url = f"https://open.spotify.com/user/{s_id}{si}"
1130
+ elif "spotify:artist:" in uri:
1131
+ s_id = uri.split(':', 2)[2]
1132
+ url = f"https://open.spotify.com/artist/{s_id}{si}"
1133
+ elif "spotify:track:" in uri:
1134
+ s_id = uri.split(':', 2)[2]
1135
+ url = f"https://open.spotify.com/track/{s_id}{si}"
1136
+ elif "spotify:album:" in uri:
1137
+ s_id = uri.split(':', 2)[2]
1138
+ url = f"https://open.spotify.com/album/{s_id}{si}"
1139
+ elif "spotify:playlist:" in uri:
1140
+ s_id = uri.split(':', 2)[2]
1141
+ url = f"https://open.spotify.com/playlist/{s_id}{si}"
1142
+
1143
+ return url
1144
+
1145
+
1146
+ # Prints the list of Spotify friends with the last listened track (-l flag)
1147
+ def spotify_list_friends(friend_activity):
1148
+
1149
+ print(f"Number of friends:\t\t{len(friend_activity['friends'])}\n")
1150
+
1151
+ for index, friend in enumerate(friend_activity["friends"]):
1152
+ sp_uri = friend["user"].get("uri").split("spotify:user:", 1)[1]
1153
+ sp_username = friend["user"].get("name")
1154
+ sp_artist = friend["track"]["artist"].get("name")
1155
+ sp_album = friend["track"]["album"].get("name")
1156
+ sp_playlist = friend["track"]["context"].get("name")
1157
+ sp_track = friend["track"].get("name")
1158
+ sp_ts = friend.get("timestamp")
1159
+ sp_album_uri = friend["track"]["album"].get("uri")
1160
+ sp_playlist_uri = friend["track"]["context"].get("uri")
1161
+ sp_track_uri = friend["track"].get("uri")
1162
+
1163
+ # if index > 0:
1164
+ # print("─" * HORIZONTAL_LINE)
1165
+ print("─" * HORIZONTAL_LINE)
1166
+ print(f"Username:\t\t\t{sp_username}")
1167
+ print(f"User URI ID:\t\t\t{sp_uri}")
1168
+ print(f"User URL:\t\t\t{spotify_convert_uri_to_url('spotify:user:' + sp_uri)}")
1169
+ print(f"\nLast played:\t\t\t{sp_artist} - {sp_track}\n")
1170
+ if 'spotify:playlist:' in sp_playlist_uri:
1171
+ print(f"Playlist:\t\t\t{sp_playlist}")
1172
+ print(f"Album:\t\t\t\t{sp_album}")
1173
+
1174
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1175
+ print(f"\nContext (Album):\t\t{sp_playlist}")
1176
+
1177
+ if 'spotify:artist:' in sp_playlist_uri:
1178
+ print(f"\nContext (Artist):\t\t{sp_playlist}")
1179
+
1180
+ print(f"\nTrack URL:\t\t\t{spotify_convert_uri_to_url(sp_track_uri)}")
1181
+ if 'spotify:playlist:' in sp_playlist_uri:
1182
+ print(f"Playlist URL:\t\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1183
+ print(f"Album URL:\t\t\t{spotify_convert_uri_to_url(sp_album_uri)}")
1184
+
1185
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1186
+ print(f"Context (Album) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1187
+
1188
+ if 'spotify:artist:' in sp_playlist_uri:
1189
+ print(f"Context (Artist) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1190
+
1191
+ apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
1192
+
1193
+ print(f"Apple search URL:\t\t{apple_search_url}")
1194
+ print(f"YouTube Music search URL:\t{youtube_music_search_url}")
1195
+ print(f"Genius lyrics URL:\t\t{genius_search_url}")
1196
+
1197
+ 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)")
1198
+
1199
+
1200
+ # Returns information for specific Spotify friend's user URI id
1201
+ def spotify_get_friend_info(friend_activity, uri):
1202
+ for friend in friend_activity["friends"]:
1203
+ sp_uri = friend["user"]["uri"].split("spotify:user:", 1)[1]
1204
+ if sp_uri == uri:
1205
+ sp_username = friend["user"].get("name")
1206
+ sp_artist = friend["track"]["artist"].get("name")
1207
+ sp_album = friend["track"]["album"].get("name")
1208
+ sp_album_uri = friend["track"]["album"].get("uri")
1209
+ sp_playlist = friend["track"]["context"].get("name")
1210
+ sp_playlist_uri = friend["track"]["context"].get("uri")
1211
+ sp_track = friend["track"].get("name")
1212
+ sp_track_uri = str(friend["track"].get("uri"))
1213
+ if "spotify:track:" in sp_track_uri:
1214
+ sp_track_uri_id = sp_track_uri.split(':', 2)[2]
1215
+ else:
1216
+ sp_track_uri_id = ""
1217
+ sp_ts = int(str(friend.get("timestamp"))[0:-3])
1218
+ return True, {"sp_uri": sp_uri, "sp_username": sp_username, "sp_artist": sp_artist, "sp_track": sp_track, "sp_track_uri": sp_track_uri, "sp_track_uri_id": sp_track_uri_id, "sp_album": sp_album, "sp_album_uri": sp_album_uri, "sp_playlist": sp_playlist, "sp_playlist_uri": sp_playlist_uri, "sp_ts": sp_ts}
1219
+ return False, {}
1220
+
1221
+
1222
+ # Returns information for specific Spotify track URI
1223
+ def spotify_get_track_info(access_token, track_uri):
1224
+ track_id = track_uri.split(':', 2)[2]
1225
+ url = "https://api.spotify.com/v1/tracks/" + track_id
1226
+ headers = {
1227
+ "Authorization": f"Bearer {access_token}",
1228
+ "Client-Id": SP_CACHED_CLIENT_ID,
1229
+ "User-Agent": SP_CACHED_USER_AGENT,
1230
+ }
1231
+ # add si parameter so link opens in native Spotify app after clicking
1232
+ si = "?si=1"
1233
+
1234
+ try:
1235
+ response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1236
+ response.raise_for_status()
1237
+ json_response = response.json()
1238
+ sp_track_duration = int(json_response.get("duration_ms") / 1000)
1239
+ sp_track_url = json_response["external_urls"].get("spotify") + si
1240
+ sp_track_name = json_response.get("name")
1241
+ sp_artist_url = json_response["artists"][0]["external_urls"].get("spotify") + si
1242
+ sp_artist_name = json_response["artists"][0].get("name")
1243
+ sp_album_url = json_response["album"]["external_urls"].get("spotify") + si
1244
+ sp_album_name = json_response["album"].get("name")
1245
+ return {"sp_track_duration": sp_track_duration, "sp_track_url": sp_track_url, "sp_artist_url": sp_artist_url, "sp_album_url": sp_album_url, "sp_track_name": sp_track_name, "sp_artist_name": sp_artist_name, "sp_album_name": sp_album_name}
1246
+ except Exception:
1247
+ raise
1248
+
1249
+
1250
+ # Returns information for specific Spotify playlist URI
1251
+ def spotify_get_playlist_info(access_token, playlist_uri):
1252
+ playlist_id = playlist_uri.split(':', 2)[2]
1253
+ url = f"https://api.spotify.com/v1/playlists/{playlist_id}?fields=name,owner,followers,external_urls"
1254
+ headers = {
1255
+ "Authorization": f"Bearer {access_token}",
1256
+ "Client-Id": SP_CACHED_CLIENT_ID,
1257
+ "User-Agent": SP_CACHED_USER_AGENT,
1258
+ }
1259
+ # add si parameter so link opens in native Spotify app after clicking
1260
+ si = "?si=1"
1261
+
1262
+ try:
1263
+ response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1264
+ response.raise_for_status()
1265
+ json_response = response.json()
1266
+ sp_playlist_name = json_response.get("name")
1267
+ sp_playlist_owner = json_response["owner"].get("display_name")
1268
+ sp_playlist_owner_url = json_response["owner"]["external_urls"].get("spotify")
1269
+ sp_playlist_followers = int(json_response["followers"].get("total"))
1270
+ sp_playlist_url = json_response["external_urls"].get("spotify") + si
1271
+ return {"sp_playlist_name": sp_playlist_name, "sp_playlist_owner": sp_playlist_owner, "sp_playlist_owner_url": sp_playlist_owner_url, "sp_playlist_followers": sp_playlist_followers, "sp_playlist_url": sp_playlist_url}
1272
+ except Exception:
1273
+ raise
1274
+
1275
+
1276
+ # Gets basic information about access token owner
1277
+ def spotify_get_current_user(access_token) -> dict | None:
1278
+ url = "https://api.spotify.com/v1/me"
1279
+ headers = {
1280
+ "Authorization": f"Bearer {access_token}",
1281
+ "Client-Id": SP_CACHED_CLIENT_ID,
1282
+ "User-Agent": SP_CACHED_USER_AGENT,
1283
+ }
1284
+
1285
+ if platform.system() != 'Windows':
1286
+ signal.signal(signal.SIGALRM, timeout_handler)
1287
+ signal.alarm(ALARM_TIMEOUT + 2)
1288
+ try:
1289
+ response = SESSION.get(url, headers=headers, timeout=FUNCTION_TIMEOUT, verify=VERIFY_SSL)
1290
+ response.raise_for_status()
1291
+ data = response.json()
1292
+
1293
+ user_info = {
1294
+ "display_name": data.get("display_name"),
1295
+ "uri": data.get("uri"),
1296
+ "is_premium": data.get("product") == "premium",
1297
+ "country": data.get("country"),
1298
+ "email": data.get("email"),
1299
+ "spotify_url": data.get("external_urls", {}).get("spotify") + "?si=1" if data.get("external_urls", {}).get("spotify") else None
1300
+ }
1301
+
1302
+ return user_info
1303
+ except Exception as e:
1304
+ print(f"* Error: {e}")
1305
+ return None
1306
+ finally:
1307
+ if platform.system() != 'Windows':
1308
+ signal.alarm(0)
1309
+
1310
+
1311
+ def spotify_macos_play_song(sp_track_uri_id, method=SPOTIFY_MACOS_PLAYING_METHOD):
1312
+ if method == "apple-script": # apple-script
1313
+ script = f'tell app "Spotify" to play track "spotify:track:{sp_track_uri_id}"'
1314
+ proc = subprocess.Popen(['osascript', '-'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
1315
+ stdout, stderr = proc.communicate(script)
1316
+ else: # trigger-url - just trigger track URL in the client
1317
+ subprocess.call(('open', spotify_convert_uri_to_url(f"spotify:track:{sp_track_uri_id}")))
1318
+
1319
+
1320
+ def spotify_macos_play_pause(action, method=SPOTIFY_MACOS_PLAYING_METHOD):
1321
+ if method == "apple-script": # apple-script
1322
+ if str(action).lower() == "pause":
1323
+ script = 'tell app "Spotify" to pause'
1324
+ proc = subprocess.Popen(['osascript', '-'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
1325
+ stdout, stderr = proc.communicate(script)
1326
+ elif str(action).lower() == "play":
1327
+ script = 'tell app "Spotify" to play'
1328
+ proc = subprocess.Popen(['osascript', '-'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
1329
+ stdout, stderr = proc.communicate(script)
1330
+
1331
+
1332
+ def spotify_linux_play_song(sp_track_uri_id, method=SPOTIFY_LINUX_PLAYING_METHOD):
1333
+ if method == "dbus-send": # dbus-send
1334
+ subprocess.call((f"dbus-send --type=method_call --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.OpenUri string:'spotify:track:{sp_track_uri_id}'"), shell=True)
1335
+ elif method == "qdbus": # qdbus
1336
+ subprocess.call((f"qdbus org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.OpenUri spotify:track:{sp_track_uri_id}"), shell=True)
1337
+ else: # trigger-url - just trigger track URL in the client
1338
+ subprocess.call(('xdg-open', spotify_convert_uri_to_url(f"spotify:track:{sp_track_uri_id}")), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
1339
+
1340
+
1341
+ def spotify_linux_play_pause(action, method=SPOTIFY_LINUX_PLAYING_METHOD):
1342
+ if method == "dbus-send": # dbus-send
1343
+ if str(action).lower() == "pause":
1344
+ subprocess.call((f"dbus-send --type=method_call --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.Pause"), shell=True)
1345
+ elif str(action).lower() == "play":
1346
+ subprocess.call((f"dbus-send --type=method_call --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.Play"), shell=True)
1347
+ elif method == "qdbus": # qdbus
1348
+ if str(action).lower() == "pause":
1349
+ subprocess.call((f"qdbus org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.Pause"), shell=True)
1350
+ elif str(action).lower() == "play":
1351
+ subprocess.call((f"qdbus org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.Play"), shell=True)
1352
+
1353
+
1354
+ def spotify_win_play_song(sp_track_uri_id, method=SPOTIFY_WINDOWS_PLAYING_METHOD):
1355
+ WIN_SPOTIFY_APP_PATH = r'%APPDATA%\Spotify\Spotify.exe'
1356
+
1357
+ if method == "start-uri": # start-uri
1358
+ subprocess.call((f"start spotify:track:{sp_track_uri_id}"), shell=True)
1359
+ elif method == "spotify-cmd": # spotify-cmd
1360
+ subprocess.call((f"{WIN_SPOTIFY_APP_PATH} --uri=spotify:track:{sp_track_uri_id}"), shell=True)
1361
+ else: # trigger-url - just trigger track URL in the client
1362
+ os.startfile(spotify_convert_uri_to_url(f"spotify:track:{sp_track_uri_id}"))
1363
+
1364
+
1365
+ # Finds an optional config file
1366
+ def find_config_file(cli_path=None):
1367
+ """
1368
+ Search for an optional config file in:
1369
+ 1) CLI-provided path (must exist if given)
1370
+ 2) ./{DEFAULT_CONFIG_FILENAME}
1371
+ 3) ~/.{DEFAULT_CONFIG_FILENAME}
1372
+ 4) script-directory/{DEFAULT_CONFIG_FILENAME}
1373
+ """
1374
+
1375
+ if cli_path:
1376
+ p = Path(os.path.expanduser(cli_path))
1377
+ return str(p) if p.is_file() else None
1378
+
1379
+ candidates = [
1380
+ Path.cwd() / DEFAULT_CONFIG_FILENAME,
1381
+ Path.home() / f".{DEFAULT_CONFIG_FILENAME}",
1382
+ Path(__file__).parent / DEFAULT_CONFIG_FILENAME,
1383
+ ]
1384
+
1385
+ for p in candidates:
1386
+ if p.is_file():
1387
+ return str(p)
1388
+ return None
1389
+
1390
+
1391
+ # Resolves an executable path by checking if it's a valid file or searching in $PATH
1392
+ def resolve_executable(path):
1393
+ if os.path.isfile(path) and os.access(path, os.X_OK):
1394
+ return path
1395
+
1396
+ found = shutil.which(path)
1397
+ if found:
1398
+ return found
1399
+
1400
+ raise FileNotFoundError(f"Could not find executable '{path}'")
1401
+
1402
+
1403
+ # Main function that monitors activity of the specified Spotify friend's user URI ID
1404
+ def spotify_monitor_friend_uri(user_uri_id, tracks, csv_file_name):
1405
+ global SP_CACHED_ACCESS_TOKEN
1406
+ sp_active_ts_start = 0
1407
+ sp_active_ts_stop = 0
1408
+ sp_active_ts_start_old = 0
1409
+ user_not_found = False
1410
+ listened_songs = 0
1411
+ listened_songs_old = 0
1412
+ looped_songs = 0
1413
+ looped_songs_old = 0
1414
+ skipped_songs = 0
1415
+ skipped_songs_old = 0
1416
+ sp_artist_old = ""
1417
+ sp_track_old = ""
1418
+ song_on_loop = 0
1419
+ error_500_counter = 0
1420
+ error_500_start_ts = 0
1421
+ error_network_issue_counter = 0
1422
+ error_network_issue_start_ts = 0
1423
+
1424
+ try:
1425
+ if csv_file_name:
1426
+ init_csv_file(csv_file_name)
1427
+ except Exception as e:
1428
+ print(f"* Error: {e}")
1429
+
1430
+ email_sent = False
1431
+
1432
+ out = f"Monitoring user {user_uri_id}"
1433
+ print(out)
1434
+ print("-" * len(out))
1435
+
1436
+ tracks_upper = {t.upper() for t in tracks}
1437
+
1438
+ # Start loop
1439
+ while True:
1440
+
1441
+ # Sometimes Spotify network functions halt even though we specified the timeout
1442
+ # To overcome this we use alarm signal functionality to kill it inevitably, not available on Windows
1443
+ if platform.system() != 'Windows':
1444
+ signal.signal(signal.SIGALRM, timeout_handler)
1445
+ signal.alarm(ALARM_TIMEOUT)
1446
+ try:
1447
+ sp_accessToken = spotify_get_access_token(SP_DC_COOKIE)
1448
+ sp_friends = spotify_get_friends_json(sp_accessToken)
1449
+ sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
1450
+ email_sent = False
1451
+ if platform.system() != 'Windows':
1452
+ signal.alarm(0)
1453
+ except TimeoutException:
1454
+ if platform.system() != 'Windows':
1455
+ signal.alarm(0)
1456
+ print(f"spotify_*() function timeout after {display_time(ALARM_TIMEOUT)}, retrying in {display_time(ALARM_RETRY)}")
1457
+ print_cur_ts("Timestamp:\t\t\t")
1458
+ time.sleep(ALARM_RETRY)
1459
+ continue
1460
+ except Exception as e:
1461
+ if platform.system() != 'Windows':
1462
+ signal.alarm(0)
1463
+
1464
+ print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
1465
+
1466
+ if "401" in str(e):
1467
+ SP_CACHED_ACCESS_TOKEN = None
1468
+
1469
+ if ('access token' in str(e)) or ('Unsuccessful token request' in str(e)):
1470
+ print(f"* Error: sp_dc might have expired!")
1471
+ if ERROR_NOTIFICATION and not email_sent:
1472
+ m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
1473
+ m_body = f"sp_dc might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1474
+ 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>"
1475
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1476
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1477
+ email_sent = True
1478
+ print_cur_ts("Timestamp:\t\t\t")
1479
+ time.sleep(SPOTIFY_ERROR_INTERVAL)
1480
+ continue
1481
+
1482
+ playlist_m_body = ""
1483
+ playlist_m_body_html = ""
1484
+ played_for_m_body = ""
1485
+ played_for_m_body_html = ""
1486
+ is_playlist = False
1487
+
1488
+ # User is found in the Spotify's friend list just after starting the tool
1489
+ if sp_found:
1490
+ user_not_found = False
1491
+
1492
+ user_info = spotify_get_current_user(sp_accessToken)
1493
+ if user_info:
1494
+ print(f"Token belongs to:\t\t{user_info.get('display_name', '')}\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
1495
+
1496
+ sp_track_uri = sp_data["sp_track_uri"]
1497
+ sp_track_uri_id = sp_data["sp_track_uri_id"]
1498
+ sp_album_uri = sp_data["sp_album_uri"]
1499
+ sp_playlist_uri = sp_data["sp_playlist_uri"]
1500
+
1501
+ sp_playlist_data = {}
1502
+ try:
1503
+ sp_track_data = spotify_get_track_info(sp_accessToken, sp_track_uri)
1504
+ if 'spotify:playlist:' in sp_playlist_uri:
1505
+ is_playlist = True
1506
+ sp_playlist_data = spotify_get_playlist_info(sp_accessToken, sp_playlist_uri)
1507
+ if not sp_playlist_data:
1508
+ is_playlist = False
1509
+ else:
1510
+ is_playlist = False
1511
+ except Exception as e:
1512
+ print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
1513
+ print_cur_ts("Timestamp:\t\t\t")
1514
+ time.sleep(SPOTIFY_ERROR_INTERVAL)
1515
+ continue
1516
+
1517
+ sp_username = sp_data["sp_username"]
1518
+
1519
+ sp_artist = sp_data["sp_artist"]
1520
+ if not sp_artist:
1521
+ sp_artist = sp_track_data["sp_artist_name"]
1522
+
1523
+ sp_track = sp_data["sp_track"]
1524
+ if not sp_track:
1525
+ sp_track = sp_track_data["sp_track_name"]
1526
+
1527
+ sp_playlist = sp_data["sp_playlist"]
1528
+
1529
+ sp_album = sp_data["sp_album"]
1530
+ if not sp_album:
1531
+ sp_album = sp_track_data["sp_album_name"]
1532
+
1533
+ sp_ts = sp_data["sp_ts"]
1534
+ cur_ts = int(time.time())
1535
+
1536
+ sp_track_duration = sp_track_data["sp_track_duration"]
1537
+ sp_track_url = sp_track_data["sp_track_url"]
1538
+ sp_artist_url = sp_track_data["sp_artist_url"]
1539
+ sp_album_url = sp_track_data["sp_album_url"]
1540
+
1541
+ sp_playlist_url = ""
1542
+ if is_playlist:
1543
+ sp_playlist_url = sp_playlist_data.get("sp_playlist_url")
1544
+ playlist_m_body = f"\nPlaylist: {sp_playlist}"
1545
+ playlist_m_body_html = f"<br>Playlist: <a href=\"{sp_playlist_url}\">{escape(sp_playlist)}</a>"
1546
+
1547
+ print(f"\nUsername:\t\t\t{sp_username}")
1548
+ print(f"User URI ID:\t\t\t{sp_data['sp_uri']}")
1549
+ print(f"\nLast played:\t\t\t{sp_artist} - {sp_track}")
1550
+ print(f"Duration:\t\t\t{display_time(sp_track_duration)}\n")
1551
+ if is_playlist:
1552
+ print(f"Playlist:\t\t\t{sp_playlist}")
1553
+
1554
+ print(f"Album:\t\t\t\t{sp_album}")
1555
+
1556
+ context_m_body = ""
1557
+ context_m_body_html = ""
1558
+
1559
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1560
+ print(f"\nContext (Album):\t\t{sp_playlist}")
1561
+ context_m_body += f"\nContext (Album): {sp_playlist}"
1562
+ context_m_body_html += f"<br>Context (Album): <a href=\"{spotify_convert_uri_to_url(sp_playlist_uri)}\">{escape(sp_playlist)}</a>"
1563
+
1564
+ if 'spotify:artist:' in sp_playlist_uri:
1565
+ print(f"\nContext (Artist):\t\t{sp_playlist}")
1566
+ context_m_body += f"\nContext (Artist): {sp_playlist}"
1567
+ context_m_body_html += f"<br>Context (Artist): <a href=\"{spotify_convert_uri_to_url(sp_playlist_uri)}\">{escape(sp_playlist)}</a>"
1568
+
1569
+ print(f"\nTrack URL:\t\t\t{sp_track_url}")
1570
+ if is_playlist:
1571
+ print(f"Playlist URL:\t\t\t{sp_playlist_url}")
1572
+ print(f"Album URL:\t\t\t{sp_album_url}")
1573
+
1574
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1575
+ print(f"Context (Album) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1576
+
1577
+ if 'spotify:artist:' in sp_playlist_uri:
1578
+ print(f"Context (Artist) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1579
+
1580
+ apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
1581
+
1582
+ print(f"Apple search URL:\t\t{apple_search_url}")
1583
+ print(f"YouTube Music search URL:\t{youtube_music_search_url}")
1584
+ print(f"Genius lyrics URL:\t\t{genius_search_url}")
1585
+
1586
+ if not is_playlist:
1587
+ sp_playlist = ""
1588
+
1589
+ print(f"\nLast activity:\t\t\t{get_date_from_ts(sp_ts)}")
1590
+
1591
+ # Friend is currently active (listens to music)
1592
+ if (cur_ts - sp_ts) <= SPOTIFY_INACTIVITY_CHECK:
1593
+ sp_active_ts_start = sp_ts - sp_track_duration
1594
+ sp_active_ts_stop = 0
1595
+ listened_songs = 1
1596
+ song_on_loop = 1
1597
+ print("\n*** Friend is currently ACTIVE !")
1598
+
1599
+ if sp_track.upper() in tracks_upper or sp_playlist.upper() in tracks_upper or sp_album.upper() in tracks_upper:
1600
+ print("*** Track/playlist/album matched with the list!")
1601
+
1602
+ try:
1603
+ if csv_file_name:
1604
+ write_csv_entry(csv_file_name, datetime.fromtimestamp(int(cur_ts)), sp_artist, sp_track, sp_playlist, sp_album, datetime.fromtimestamp(int(sp_ts)))
1605
+ except Exception as e:
1606
+ print(f"* Error: {e}")
1607
+
1608
+ if ACTIVE_NOTIFICATION:
1609
+ m_subject = f"Spotify user {sp_username} is active: '{sp_artist} - {sp_track}'"
1610
+ 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: ')}"
1611
+ 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>"
1612
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1613
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1614
+
1615
+ if TRACK_SONGS and sp_track_uri_id:
1616
+ if platform.system() == 'Darwin': # macOS
1617
+ spotify_macos_play_song(sp_track_uri_id)
1618
+ elif platform.system() == 'Windows': # Windows
1619
+ spotify_win_play_song(sp_track_uri_id)
1620
+ else: # Linux variants
1621
+ spotify_linux_play_song(sp_track_uri_id)
1622
+
1623
+ # Friend is currently offline (does not play music)
1624
+ else:
1625
+ sp_active_ts_stop = sp_ts
1626
+ print(f"\n*** Friend is OFFLINE for: {calculate_timespan(int(cur_ts), int(sp_ts))}")
1627
+
1628
+ print(f"\nTracks/playlists/albums to monitor: {tracks}")
1629
+ print_cur_ts("\nTimestamp:\t\t\t")
1630
+
1631
+ sp_ts_old = sp_ts
1632
+ alive_counter = 0
1633
+
1634
+ email_sent = False
1635
+
1636
+ # Main loop
1637
+ while True:
1638
+
1639
+ while True:
1640
+ # Sometimes Spotify network functions halt even though we specified the timeout
1641
+ # To overcome this we use alarm signal functionality to kill it inevitably, not available on Windows
1642
+ if platform.system() != 'Windows':
1643
+ signal.signal(signal.SIGALRM, timeout_handler)
1644
+ signal.alarm(ALARM_TIMEOUT)
1645
+ try:
1646
+ sp_accessToken = spotify_get_access_token(SP_DC_COOKIE)
1647
+ sp_friends = spotify_get_friends_json(sp_accessToken)
1648
+ sp_found, sp_data = spotify_get_friend_info(sp_friends, user_uri_id)
1649
+ email_sent = False
1650
+ if platform.system() != 'Windows':
1651
+ signal.alarm(0)
1652
+ break
1653
+ except TimeoutException:
1654
+ if platform.system() != 'Windows':
1655
+ signal.alarm(0)
1656
+ print(f"spotify_*() function timeout after {display_time(ALARM_TIMEOUT)}, retrying in {display_time(ALARM_RETRY)}")
1657
+ print_cur_ts("Timestamp:\t\t\t")
1658
+ time.sleep(ALARM_RETRY)
1659
+ except Exception as e:
1660
+ if platform.system() != 'Windows':
1661
+ signal.alarm(0)
1662
+
1663
+ if "401" in str(e):
1664
+ SP_CACHED_ACCESS_TOKEN = None
1665
+
1666
+ str_matches = ["500 server", "504 server", "502 server", "503 server"]
1667
+ if any(x in str(e).lower() for x in str_matches):
1668
+ if not error_500_start_ts:
1669
+ error_500_start_ts = int(time.time())
1670
+ error_500_counter = 1
1671
+ else:
1672
+ error_500_counter += 1
1673
+
1674
+ str_matches = ["timed out", "timeout", "name resolution", "failed to resolve", "family not supported", "429 client", "aborted"]
1675
+ if any(x in str(e).lower() for x in str_matches) or str(e) == '':
1676
+ if not error_network_issue_start_ts:
1677
+ error_network_issue_start_ts = int(time.time())
1678
+ error_network_issue_counter = 1
1679
+ else:
1680
+ error_network_issue_counter += 1
1681
+
1682
+ if error_500_start_ts and (error_500_counter >= ERROR_500_NUMBER_LIMIT and (int(time.time()) - error_500_start_ts) >= ERROR_500_TIME_LIMIT):
1683
+ print(f"* Error 50x ({error_500_counter}x times in the last {display_time((int(time.time()) - error_500_start_ts))}): '{e}'")
1684
+ print_cur_ts("Timestamp:\t\t\t")
1685
+ error_500_start_ts = 0
1686
+ error_500_counter = 0
1687
+
1688
+ elif error_network_issue_start_ts and (error_network_issue_counter >= ERROR_NETWORK_ISSUES_NUMBER_LIMIT and (int(time.time()) - error_network_issue_start_ts) >= ERROR_NETWORK_ISSUES_TIME_LIMIT):
1689
+ print(f"* Error with network ({error_network_issue_counter}x times in the last {display_time((int(time.time()) - error_network_issue_start_ts))}): '{e}'")
1690
+ print_cur_ts("Timestamp:\t\t\t")
1691
+ error_network_issue_start_ts = 0
1692
+ error_network_issue_counter = 0
1693
+
1694
+ elif not error_500_start_ts and not error_network_issue_start_ts:
1695
+ print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: '{e}'")
1696
+ if ('access token' in str(e)) or ('Unsuccessful token request' in str(e)):
1697
+ print(f"* Error: sp_dc might have expired!")
1698
+ if ERROR_NOTIFICATION and not email_sent:
1699
+ m_subject = f"spotify_monitor: sp_dc might have expired! (uri: {user_uri_id})"
1700
+ m_body = f"sp_dc might have expired!\n{e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1701
+ 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>"
1702
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1703
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1704
+ email_sent = True
1705
+ print_cur_ts("Timestamp:\t\t\t")
1706
+ time.sleep(SPOTIFY_ERROR_INTERVAL)
1707
+
1708
+ if sp_found is False:
1709
+ # User disappeared from the Spotify's friend list
1710
+ if user_not_found is False:
1711
+ 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")
1712
+ if ERROR_NOTIFICATION:
1713
+ m_subject = f"Spotify user {user_uri_id} ({sp_username}) disappeared!"
1714
+ 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: ')}"
1715
+ 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>"
1716
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1717
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1718
+ print_cur_ts("Timestamp:\t\t\t")
1719
+ user_not_found = True
1720
+ time.sleep(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)
1721
+ continue
1722
+ else:
1723
+ # User reappeared in the Spotify's friend list
1724
+ if user_not_found is True:
1725
+ print(f"Spotify user {user_uri_id} ({sp_username}) appeared again!")
1726
+ if ERROR_NOTIFICATION:
1727
+ m_subject = f"Spotify user {user_uri_id} ({sp_username}) appeared!"
1728
+ m_body = f"Spotify user {user_uri_id} ({sp_username}) appeared again!{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1729
+ 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>"
1730
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1731
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1732
+ print_cur_ts("Timestamp:\t\t\t")
1733
+
1734
+ user_not_found = False
1735
+ sp_ts = sp_data["sp_ts"]
1736
+ cur_ts = int(time.time())
1737
+ # Track has changed
1738
+ if sp_ts != sp_ts_old:
1739
+ sp_artist_old = sp_artist
1740
+ sp_track_old = sp_track
1741
+ alive_counter = 0
1742
+ sp_playlist = sp_data["sp_playlist"]
1743
+ sp_track_uri = sp_data["sp_track_uri"]
1744
+ sp_track_uri_id = sp_data["sp_track_uri_id"]
1745
+ sp_album_uri = sp_data["sp_album_uri"]
1746
+ sp_playlist_uri = sp_data["sp_playlist_uri"]
1747
+ try:
1748
+ sp_track_data = spotify_get_track_info(sp_accessToken, sp_track_uri)
1749
+ if 'spotify:playlist:' in sp_playlist_uri:
1750
+ is_playlist = True
1751
+ sp_playlist_data = spotify_get_playlist_info(sp_accessToken, sp_playlist_uri)
1752
+ if not sp_playlist_data:
1753
+ is_playlist = False
1754
+ else:
1755
+ is_playlist = False
1756
+ except Exception as e:
1757
+ print(f"* Error, retrying in {display_time(SPOTIFY_ERROR_INTERVAL)}: {e}")
1758
+ print_cur_ts("Timestamp:\t\t\t")
1759
+ time.sleep(SPOTIFY_ERROR_INTERVAL)
1760
+ continue
1761
+
1762
+ sp_username = sp_data["sp_username"]
1763
+
1764
+ sp_artist = sp_data["sp_artist"]
1765
+ if not sp_artist:
1766
+ sp_artist = sp_track_data["sp_artist_name"]
1767
+
1768
+ sp_track = sp_data["sp_track"]
1769
+ if not sp_track:
1770
+ sp_track = sp_track_data["sp_track_name"]
1771
+
1772
+ sp_album = sp_data["sp_album"]
1773
+ if not sp_album:
1774
+ sp_album = sp_track_data["sp_album_name"]
1775
+
1776
+ sp_track_duration = sp_track_data["sp_track_duration"]
1777
+ sp_track_url = sp_track_data["sp_track_url"]
1778
+ sp_artist_url = sp_track_data["sp_artist_url"]
1779
+ sp_album_url = sp_track_data["sp_album_url"]
1780
+
1781
+ # If tracking functionality is enabled then play the current song via Spotify client
1782
+
1783
+ if TRACK_SONGS and sp_track_uri_id:
1784
+ if platform.system() == 'Darwin': # macOS
1785
+ spotify_macos_play_song(sp_track_uri_id)
1786
+ elif platform.system() == 'Windows': # Windows
1787
+ spotify_win_play_song(sp_track_uri_id)
1788
+ else: # Linux variants
1789
+ spotify_linux_play_song(sp_track_uri_id)
1790
+
1791
+ if is_playlist:
1792
+ sp_playlist_url = sp_playlist_data.get("sp_playlist_url")
1793
+ playlist_m_body = f"\nPlaylist: {sp_playlist}"
1794
+ playlist_m_body_html = f"<br>Playlist: <a href=\"{sp_playlist_url}\">{escape(sp_playlist)}</a>"
1795
+ else:
1796
+ playlist_m_body = ""
1797
+ playlist_m_body_html = ""
1798
+
1799
+ if sp_artist == sp_artist_old and sp_track == sp_track_old:
1800
+ song_on_loop += 1
1801
+ if song_on_loop == SONG_ON_LOOP_VALUE:
1802
+ looped_songs += 1
1803
+ else:
1804
+ song_on_loop = 1
1805
+
1806
+ print(f"Spotify user:\t\t\t{sp_username}")
1807
+ print(f"\nLast played:\t\t\t{sp_artist} - {sp_track}")
1808
+ print(f"Duration:\t\t\t{display_time(sp_track_duration)}")
1809
+
1810
+ listened_songs += 1
1811
+
1812
+ if (sp_ts - sp_ts_old) < (sp_track_duration - 1):
1813
+ played_for_time = sp_ts - sp_ts_old
1814
+ listened_percentage = (played_for_time) / (sp_track_duration - 1)
1815
+ played_for = display_time(played_for_time)
1816
+ if listened_percentage <= SKIPPED_SONG_THRESHOLD:
1817
+ played_for += f" - SKIPPED ({int(listened_percentage * 100)}%)"
1818
+ skipped_songs += 1
1819
+ else:
1820
+ played_for += f" ({int(listened_percentage * 100)}%)"
1821
+ print(f"Played for:\t\t\t{played_for}")
1822
+ played_for_m_body = f"\nPlayed for: {played_for}"
1823
+ played_for_m_body_html = f"<br>Played for: {played_for}"
1824
+ else:
1825
+ played_for_m_body = ""
1826
+ played_for_m_body_html = ""
1827
+
1828
+ if is_playlist:
1829
+ print(f"Playlist:\t\t\t{sp_playlist}")
1830
+
1831
+ print(f"Album:\t\t\t\t{sp_album}")
1832
+
1833
+ context_m_body = ""
1834
+ context_m_body_html = ""
1835
+
1836
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1837
+ print(f"\nContext (Album):\t\t{sp_playlist}")
1838
+ context_m_body += f"\nContext (Album): {sp_playlist}"
1839
+ context_m_body_html += f"<br>Context (Album): <a href=\"{spotify_convert_uri_to_url(sp_playlist_uri)}\">{escape(sp_playlist)}</a>"
1840
+
1841
+ if 'spotify:artist:' in sp_playlist_uri:
1842
+ print(f"\nContext (Artist):\t\t{sp_playlist}")
1843
+ context_m_body += f"\nContext (Artist): {sp_playlist}"
1844
+ context_m_body_html += f"<br>Context (Artist): <a href=\"{spotify_convert_uri_to_url(sp_playlist_uri)}\">{escape(sp_playlist)}</a>"
1845
+
1846
+ print(f"Last activity:\t\t\t{get_date_from_ts(sp_ts)}")
1847
+
1848
+ print(f"\nTrack URL:\t\t\t{sp_track_url}")
1849
+ if is_playlist:
1850
+ print(f"Playlist URL:\t\t\t{sp_playlist_url}")
1851
+ print(f"Album URL:\t\t\t{sp_album_url}")
1852
+
1853
+ if 'spotify:album:' in sp_playlist_uri and sp_playlist != sp_album:
1854
+ print(f"Context (Album) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1855
+
1856
+ if 'spotify:artist:' in sp_playlist_uri:
1857
+ print(f"Context (Artist) URL:\t\t{spotify_convert_uri_to_url(sp_playlist_uri)}")
1858
+
1859
+ apple_search_url, genius_search_url, youtube_music_search_url = get_apple_genius_search_urls(str(sp_artist), str(sp_track))
1860
+
1861
+ print(f"Apple search URL:\t\t{apple_search_url}")
1862
+ print(f"YouTube Music search URL:\t{youtube_music_search_url}")
1863
+ print(f"Genius lyrics URL:\t\t{genius_search_url}")
1864
+
1865
+ if not is_playlist:
1866
+ sp_playlist = ""
1867
+
1868
+ if song_on_loop == SONG_ON_LOOP_VALUE:
1869
+ print("─" * HORIZONTAL_LINE)
1870
+ print(f"User plays song on LOOP ({song_on_loop} times)")
1871
+ print("─" * HORIZONTAL_LINE)
1872
+
1873
+ # Friend got active after being offline
1874
+ if (cur_ts - sp_ts_old) > SPOTIFY_INACTIVITY_CHECK and sp_active_ts_stop > 0:
1875
+
1876
+ sp_active_ts_start = sp_ts - sp_track_duration
1877
+
1878
+ listened_songs = 1
1879
+ skipped_songs = 0
1880
+ looped_songs = 0
1881
+
1882
+ 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)})")
1883
+ 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)})"
1884
+ 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)}"
1885
+ friend_active_m_body_html = f"<br><br>Friend got active after being offline for <b>{calculate_timespan(int(sp_active_ts_start), int(sp_active_ts_stop))}</b><br>Last activity (before getting offline): <b>{get_date_from_ts(sp_active_ts_stop)}</b>"
1886
+ if (sp_active_ts_start - sp_active_ts_stop) < 30:
1887
+ listened_songs = listened_songs_old
1888
+ skipped_songs = skipped_songs_old
1889
+ looped_songs = looped_songs_old
1890
+ print(f"*** Inactivity timer ({display_time(SPOTIFY_INACTIVITY_CHECK)}) value might be too low, readjusting session start back to {get_short_date_from_ts(sp_active_ts_start_old)}")
1891
+ friend_active_m_body += f"\nInactivity timer ({display_time(SPOTIFY_INACTIVITY_CHECK)}) value might be too low, readjusting session start back to {get_short_date_from_ts(sp_active_ts_start_old)}"
1892
+ friend_active_m_body_html += f"<br>Inactivity timer (<b>{display_time(SPOTIFY_INACTIVITY_CHECK)}</b>) value might be <b>too low</b>, readjusting session start back to <b>{get_short_date_from_ts(sp_active_ts_start_old)}</b>"
1893
+ if sp_active_ts_start_old > 0:
1894
+ sp_active_ts_start = sp_active_ts_start_old
1895
+ sp_active_ts_stop = 0
1896
+
1897
+ 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: ')}"
1898
+ 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>"
1899
+
1900
+ if ACTIVE_NOTIFICATION:
1901
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1902
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1903
+ email_sent = True
1904
+
1905
+ on_the_list = False
1906
+ if sp_track.upper() in tracks_upper or sp_playlist.upper() in tracks_upper or sp_album.upper() in tracks_upper:
1907
+ print("\n*** Track/playlist/album matched with the list!")
1908
+ on_the_list = True
1909
+
1910
+ if (TRACK_NOTIFICATION and on_the_list and not email_sent) or (SONG_NOTIFICATION and not email_sent):
1911
+ m_subject = f"Spotify user {sp_username}: '{sp_artist} - {sp_track}'"
1912
+ 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: ')}"
1913
+ 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>"
1914
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1915
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1916
+ email_sent = True
1917
+
1918
+ if song_on_loop == SONG_ON_LOOP_VALUE and SONG_ON_LOOP_NOTIFICATION:
1919
+ m_subject = f"Spotify user {sp_username} plays song on loop: '{sp_artist} - {sp_track}'"
1920
+ 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: ')}"
1921
+ 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>"
1922
+ if not email_sent:
1923
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1924
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1925
+
1926
+ try:
1927
+ if csv_file_name:
1928
+ write_csv_entry(csv_file_name, datetime.fromtimestamp(int(cur_ts)), sp_artist, sp_track, sp_playlist, sp_album, datetime.fromtimestamp(int(sp_ts)))
1929
+ except Exception as e:
1930
+ print(f"* Error: {e}")
1931
+
1932
+ print_cur_ts("\nTimestamp:\t\t\t")
1933
+ sp_ts_old = sp_ts
1934
+ # Track has not changed
1935
+ else:
1936
+ alive_counter += 1
1937
+ # Friend got inactive
1938
+ if (cur_ts - sp_ts) > SPOTIFY_INACTIVITY_CHECK and sp_active_ts_start > 0:
1939
+ sp_active_ts_stop = sp_ts
1940
+ print(f"*** Friend got INACTIVE after listening to music for {calculate_timespan(int(sp_active_ts_stop), int(sp_active_ts_start))}")
1941
+ 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 ')}")
1942
+
1943
+ listened_songs_text = f"*** User played {listened_songs} songs"
1944
+ listened_songs_mbody = f"\n\nUser played {listened_songs} songs"
1945
+ listened_songs_mbody_html = f"<br><br>User played <b>{listened_songs}</b> songs"
1946
+
1947
+ if skipped_songs > 0:
1948
+ skipped_songs_text = f", skipped {skipped_songs} songs ({int((skipped_songs / listened_songs) * 100)}%)"
1949
+ listened_songs_text += skipped_songs_text
1950
+ listened_songs_mbody += skipped_songs_text
1951
+ listened_songs_mbody_html += f", skipped <b>{skipped_songs}</b> songs <b>({int((skipped_songs / listened_songs) * 100)}%)</b>"
1952
+
1953
+ if looped_songs > 0:
1954
+ looped_songs_text = f"\n*** User played {looped_songs} songs on loop"
1955
+ looped_songs_mbody = f"\nUser played {looped_songs} songs on loop"
1956
+ looped_songs_mbody_html = f"<br>User played <b>{looped_songs}</b> songs on loop"
1957
+ listened_songs_text += looped_songs_text
1958
+ listened_songs_mbody += looped_songs_mbody
1959
+ listened_songs_mbody_html += looped_songs_mbody_html
1960
+
1961
+ print(listened_songs_text)
1962
+
1963
+ print(f"*** Last activity:\t\t{get_date_from_ts(sp_active_ts_stop)} (inactive timer: {display_time(SPOTIFY_INACTIVITY_CHECK)})")
1964
+ # If tracking functionality is enabled then either pause the current song via Spotify client or play the indicated SP_USER_GOT_OFFLINE_TRACK_ID "finishing" song
1965
+ if TRACK_SONGS:
1966
+ if SP_USER_GOT_OFFLINE_TRACK_ID:
1967
+ if platform.system() == 'Darwin': # macOS
1968
+ spotify_macos_play_song(SP_USER_GOT_OFFLINE_TRACK_ID)
1969
+ if SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE > 0:
1970
+ time.sleep(SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE)
1971
+ spotify_macos_play_pause("pause")
1972
+ elif platform.system() == 'Windows': # Windows
1973
+ pass
1974
+ else: # Linux variants
1975
+ spotify_linux_play_song(SP_USER_GOT_OFFLINE_TRACK_ID)
1976
+ if SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE > 0:
1977
+ time.sleep(SP_USER_GOT_OFFLINE_DELAY_BEFORE_PAUSE)
1978
+ spotify_linux_play_pause("pause")
1979
+ else:
1980
+ if platform.system() == 'Darwin': # macOS
1981
+ spotify_macos_play_pause("pause")
1982
+ elif platform.system() == 'Windows': # Windows
1983
+ pass
1984
+ else: # Linux variants
1985
+ spotify_linux_play_pause("pause")
1986
+ if INACTIVE_NOTIFICATION:
1987
+ 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)})"
1988
+ 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: ')}"
1989
+ 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>"
1990
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1991
+ send_email(m_subject, m_body, m_body_html, SMTP_SSL)
1992
+ email_sent = True
1993
+ sp_active_ts_start_old = sp_active_ts_start
1994
+ sp_active_ts_start = 0
1995
+ listened_songs_old = listened_songs
1996
+ skipped_songs_old = skipped_songs
1997
+ looped_songs_old = looped_songs
1998
+ listened_songs = 0
1999
+ looped_songs = 0
2000
+ skipped_songs = 0
2001
+ print_cur_ts("\nTimestamp:\t\t\t")
2002
+
2003
+ if LIVENESS_CHECK_COUNTER and alive_counter >= LIVENESS_CHECK_COUNTER:
2004
+ print_cur_ts("Liveness check, timestamp:\t")
2005
+ alive_counter = 0
2006
+
2007
+ time.sleep(SPOTIFY_CHECK_INTERVAL)
2008
+
2009
+ ERROR_500_ZERO_TIME_LIMIT = ERROR_500_TIME_LIMIT + SPOTIFY_CHECK_INTERVAL
2010
+ if SPOTIFY_CHECK_INTERVAL * ERROR_500_NUMBER_LIMIT > ERROR_500_ZERO_TIME_LIMIT:
2011
+ ERROR_500_ZERO_TIME_LIMIT = SPOTIFY_CHECK_INTERVAL * (ERROR_500_NUMBER_LIMIT + 1)
2012
+
2013
+ if error_500_start_ts and ((int(time.time()) - error_500_start_ts) >= ERROR_500_ZERO_TIME_LIMIT):
2014
+ error_500_start_ts = 0
2015
+ error_500_counter = 0
2016
+
2017
+ ERROR_NETWORK_ZERO_TIME_LIMIT = ERROR_NETWORK_ISSUES_TIME_LIMIT + SPOTIFY_CHECK_INTERVAL
2018
+ if SPOTIFY_CHECK_INTERVAL * ERROR_NETWORK_ISSUES_NUMBER_LIMIT > ERROR_NETWORK_ZERO_TIME_LIMIT:
2019
+ ERROR_NETWORK_ZERO_TIME_LIMIT = SPOTIFY_CHECK_INTERVAL * (ERROR_NETWORK_ISSUES_NUMBER_LIMIT + 1)
2020
+
2021
+ if error_network_issue_start_ts and ((int(time.time()) - error_network_issue_start_ts) >= ERROR_NETWORK_ZERO_TIME_LIMIT):
2022
+ error_network_issue_start_ts = 0
2023
+ error_network_issue_counter = 0
2024
+
2025
+ # User is not found in the Spotify's friend list just after starting the tool
2026
+ else:
2027
+ if user_not_found is False:
2028
+ 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)}.")
2029
+ print_cur_ts("Timestamp:\t\t\t")
2030
+ user_not_found = True
2031
+ time.sleep(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)
2032
+ continue
2033
+
2034
+
2035
+ def main():
2036
+ global CLI_CONFIG_PATH, DOTENV_FILE, LIVENESS_CHECK_COUNTER, SP_DC_COOKIE, CSV_FILE, MONITOR_LIST_FILE, FILE_SUFFIX, DISABLE_LOGGING, SP_LOGFILE, ACTIVE_NOTIFICATION, INACTIVE_NOTIFICATION, TRACK_NOTIFICATION, SONG_NOTIFICATION, SONG_ON_LOOP_NOTIFICATION, ERROR_NOTIFICATION, SPOTIFY_CHECK_INTERVAL, SPOTIFY_INACTIVITY_CHECK, SPOTIFY_ERROR_INTERVAL, SPOTIFY_DISAPPEARED_CHECK_INTERVAL, TRACK_SONGS, SMTP_PASSWORD, stdout_bck
2037
+
2038
+ if "--generate-config" in sys.argv:
2039
+ print(CONFIG_BLOCK.strip("\n"))
2040
+ sys.exit(0)
2041
+
2042
+ if "--version" in sys.argv:
2043
+ print(f"{os.path.basename(sys.argv[0])} v{VERSION}")
2044
+ sys.exit(0)
2045
+
2046
+ stdout_bck = sys.stdout
2047
+
2048
+ signal.signal(signal.SIGINT, signal_handler)
2049
+ signal.signal(signal.SIGTERM, signal_handler)
2050
+
2051
+ clear_screen(CLEAR_SCREEN)
2052
+
2053
+ print(f"Spotify Monitoring Tool v{VERSION}\n")
2054
+
2055
+ parser = argparse.ArgumentParser(
2056
+ prog="spotify_monitor",
2057
+ description=("Monitor a Spotify friend's activity and send customizable email alerts [ https://github.com/misiektoja/spotify_monitor/ ]"), formatter_class=argparse.RawTextHelpFormatter
2058
+ )
2059
+
2060
+ # Positional
2061
+ parser.add_argument(
2062
+ "user_id",
2063
+ nargs="?",
2064
+ metavar="SPOTIFY_USER_URI_ID",
2065
+ help="Spotify user URI ID"
2066
+ )
2067
+
2068
+ # Version, just to list in help, it is handled earlier
2069
+ parser.add_argument(
2070
+ "--version",
2071
+ action="version",
2072
+ version=f"%(prog)s v{VERSION}"
2073
+ )
2074
+
2075
+ # Configuration & dotenv files
2076
+ conf = parser.add_argument_group("Configuration & dotenv files")
2077
+ conf.add_argument(
2078
+ "--config-file",
2079
+ dest="config_file",
2080
+ metavar="PATH",
2081
+ help="Location of the optional config file",
2082
+ )
2083
+ conf.add_argument(
2084
+ "--generate-config",
2085
+ action="store_true",
2086
+ help="Print default config template and exit",
2087
+ )
2088
+ conf.add_argument(
2089
+ "--env-file",
2090
+ dest="env_file",
2091
+ metavar="PATH",
2092
+ help="Path to optional dotenv file (auto-search if not set, disable with 'none')",
2093
+ )
2094
+
2095
+ # API credentials
2096
+ creds = parser.add_argument_group("API credentials")
2097
+ creds.add_argument(
2098
+ "-u", "--spotify-dc-cookie",
2099
+ dest="spotify_dc_cookie",
2100
+ metavar="SP_DC_COOKIE",
2101
+ help="Spotify sp_dc cookie"
2102
+ )
2103
+
2104
+ # Notifications
2105
+ notify = parser.add_argument_group("Notifications")
2106
+ notify.add_argument(
2107
+ "-a", "--notify-active",
2108
+ dest="notify_active",
2109
+ action="store_true",
2110
+ default=None,
2111
+ help="Email when user becomes active"
2112
+ )
2113
+ notify.add_argument(
2114
+ "-i", "--notify-inactive",
2115
+ dest="notify_inactive",
2116
+ action="store_true",
2117
+ default=None,
2118
+ help="Email when user goes inactive"
2119
+ )
2120
+ notify.add_argument(
2121
+ "-t", "--notify-track",
2122
+ dest="notify_track",
2123
+ action="store_true",
2124
+ default=None,
2125
+ help="Email when a monitored track/playlist/album plays"
2126
+ )
2127
+ notify.add_argument(
2128
+ "-j", "--notify-song-changes",
2129
+ dest="notify_song_changes",
2130
+ action="store_true",
2131
+ default=None,
2132
+ help="Email on every song change"
2133
+ )
2134
+ notify.add_argument(
2135
+ "-x", "--notify-loop",
2136
+ dest="notify_loop",
2137
+ action="store_true",
2138
+ default=None,
2139
+ help="Email when user plays a song on loop"
2140
+ )
2141
+ notify.add_argument(
2142
+ "-e", "--no-error-notify",
2143
+ dest="notify_errors",
2144
+ action="store_false",
2145
+ default=None,
2146
+ help="Disable email on errors (e.g. expired sp_dc)"
2147
+ )
2148
+ notify.add_argument(
2149
+ "--send-test-email",
2150
+ dest="send_test_email",
2151
+ action="store_true",
2152
+ help="Send test email to verify SMTP settings"
2153
+ )
2154
+
2155
+ # Intervals & timers
2156
+ times = parser.add_argument_group("Intervals & timers")
2157
+ times.add_argument(
2158
+ "-c", "--check-interval",
2159
+ dest="check_interval",
2160
+ metavar="SECONDS",
2161
+ type=int,
2162
+ help="Time between monitoring checks, in seconds"
2163
+ )
2164
+ times.add_argument(
2165
+ "-o", "--offline-timer",
2166
+ dest="offline_timer",
2167
+ metavar="SECONDS",
2168
+ type=int,
2169
+ help="Time required to mark inactive user as offline, in seconds"
2170
+ )
2171
+ times.add_argument(
2172
+ "-m", "--disappeared-timer",
2173
+ dest="disappeared_timer",
2174
+ metavar="SECONDS",
2175
+ type=int,
2176
+ help="Wait time between checks once the user disappears from friends list, in seconds"
2177
+ )
2178
+
2179
+ # Listing
2180
+ listing = parser.add_argument_group("Listing")
2181
+ listing.add_argument(
2182
+ "-l", "--list-friends",
2183
+ dest="list_friends",
2184
+ action="store_true",
2185
+ help="List Spotify friends with their last listened track"
2186
+ )
2187
+ listing.add_argument(
2188
+ "-v", "--show-user-info",
2189
+ dest="show_user_info",
2190
+ action="store_true",
2191
+ help="Get basic information about access token owner"
2192
+ )
2193
+
2194
+ # Features & output
2195
+ opts = parser.add_argument_group("Features & output")
2196
+ opts.add_argument(
2197
+ "-g", "--track-in-spotify",
2198
+ dest="track_in_spotify",
2199
+ action="store_true",
2200
+ default=None,
2201
+ help="Auto‑play each listened song in your Spotify client"
2202
+ )
2203
+ opts.add_argument(
2204
+ "-b", "--csv-file",
2205
+ dest="csv_file",
2206
+ metavar="CSV_FILE",
2207
+ type=str,
2208
+ help="Write every listened track to CSV file"
2209
+ )
2210
+ opts.add_argument(
2211
+ "-s", "--monitor-list",
2212
+ dest="monitor_list",
2213
+ metavar="TRACKS_FILE",
2214
+ type=str,
2215
+ help="Filename with Spotify tracks/playlists/albums to alert on"
2216
+ )
2217
+ opts.add_argument(
2218
+ "-y", "--file-suffix",
2219
+ dest="file_suffix",
2220
+ metavar="SUFFIX",
2221
+ type=str,
2222
+ help="File suffix to append to output filenames instead of Spotify user URI ID"
2223
+ )
2224
+ opts.add_argument(
2225
+ "-d", "--disable-logging",
2226
+ dest="disable_logging",
2227
+ action="store_true",
2228
+ default=None,
2229
+ help="Disable logging to file spotify_monitor_<user_uri_id/file_suffix>.log"
2230
+ )
2231
+
2232
+ args = parser.parse_args()
2233
+
2234
+ if len(sys.argv) == 1:
2235
+ parser.print_help(sys.stderr)
2236
+ sys.exit(1)
2237
+
2238
+ if args.config_file:
2239
+ CLI_CONFIG_PATH = os.path.expanduser(args.config_file)
2240
+
2241
+ cfg_path = find_config_file(CLI_CONFIG_PATH)
2242
+
2243
+ if not cfg_path and CLI_CONFIG_PATH:
2244
+ print(f"* Error: Config file '{CLI_CONFIG_PATH}' does not exist")
2245
+ sys.exit(1)
2246
+
2247
+ if cfg_path:
2248
+ try:
2249
+ with open(cfg_path, "r") as cf:
2250
+ exec(cf.read(), globals())
2251
+ except Exception as e:
2252
+ print(f"* Error loading config file '{cfg_path}': {e}")
2253
+ sys.exit(1)
2254
+
2255
+ if args.env_file:
2256
+ DOTENV_FILE = os.path.expanduser(args.env_file)
2257
+ else:
2258
+ if DOTENV_FILE:
2259
+ DOTENV_FILE = os.path.expanduser(DOTENV_FILE)
2260
+
2261
+ if DOTENV_FILE and DOTENV_FILE.lower() == 'none':
2262
+ env_path = None
2263
+ else:
2264
+ try:
2265
+ from dotenv import load_dotenv, find_dotenv
2266
+
2267
+ if DOTENV_FILE:
2268
+ env_path = DOTENV_FILE
2269
+ if not os.path.isfile(env_path):
2270
+ print(f"* Warning: dotenv file '{env_path}' does not exist\n")
2271
+ else:
2272
+ load_dotenv(env_path, override=True)
2273
+ else:
2274
+ env_path = find_dotenv() or None
2275
+ if env_path:
2276
+ load_dotenv(env_path, override=True)
2277
+ except ImportError:
2278
+ env_path = DOTENV_FILE if DOTENV_FILE else None
2279
+ if env_path:
2280
+ 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")
2281
+
2282
+ if env_path:
2283
+ for secret in SECRET_KEYS:
2284
+ val = os.getenv(secret)
2285
+ if val is not None:
2286
+ globals()[secret] = val
2287
+
2288
+ if not check_internet():
2289
+ sys.exit(1)
2290
+
2291
+ if args.send_test_email:
2292
+ print("* Sending test email notification ...\n")
2293
+ if send_email("spotify_monitor: test email", "This is test email - your SMTP settings seems to be correct !", "", SMTP_SSL, smtp_timeout=5) == 0:
2294
+ print("* Email sent successfully !")
2295
+ else:
2296
+ sys.exit(1)
2297
+ sys.exit(0)
2298
+
2299
+ if args.check_interval:
2300
+ SPOTIFY_CHECK_INTERVAL = args.check_interval
2301
+ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / SPOTIFY_CHECK_INTERVAL
2302
+
2303
+ if args.offline_timer:
2304
+ SPOTIFY_INACTIVITY_CHECK = args.offline_timer
2305
+
2306
+ if args.disappeared_timer:
2307
+ SPOTIFY_DISAPPEARED_CHECK_INTERVAL = args.disappeared_timer
2308
+
2309
+ if args.spotify_dc_cookie:
2310
+ SP_DC_COOKIE = args.spotify_dc_cookie
2311
+
2312
+ if not SP_DC_COOKIE or SP_DC_COOKIE == "your_sp_dc_cookie_value":
2313
+ print("* Error: SP_DC_COOKIE (-u / --spotify-dc-cookie) value is empty or incorrect")
2314
+ sys.exit(1)
2315
+
2316
+ if args.show_user_info:
2317
+ print("* Getting basic information about access token owner ...\n")
2318
+ try:
2319
+ accessToken = spotify_get_access_token(SP_DC_COOKIE)
2320
+ user_info = spotify_get_current_user(accessToken)
2321
+
2322
+ if user_info:
2323
+ print(f"Token belongs to:\n")
2324
+
2325
+ print(f"Username:\t\t{user_info.get('display_name', '')}")
2326
+ print(f"User URI ID:\t\t{user_info.get('uri', '').split('spotify:user:', 1)[1]}")
2327
+ print(f"User URL:\t\t{user_info.get('spotify_url', '')}")
2328
+ print(f"User e-mail:\t\t{user_info.get('email', '')}")
2329
+ print(f"User country:\t\t{user_info.get('country', '')}")
2330
+ print(f"Is Premium?:\t\t{user_info.get('is_premium', '')}")
2331
+ else:
2332
+ print("Failed to retrieve user info.")
2333
+
2334
+ print("─" * HORIZONTAL_LINE)
2335
+ except Exception as e:
2336
+ print(f"* Error: {e}")
2337
+ sys.exit(1)
2338
+ sys.exit(0)
2339
+
2340
+ if args.list_friends:
2341
+ print("* Listing Spotify friends ...\n")
2342
+ try:
2343
+ accessToken = spotify_get_access_token(SP_DC_COOKIE)
2344
+ user_info = spotify_get_current_user(accessToken)
2345
+ if user_info:
2346
+ print(f"Token belongs to:\t\t{user_info.get('display_name', '')}\n\t\t\t\t[ {user_info.get('spotify_url')} ]")
2347
+ sp_friends = spotify_get_friends_json(accessToken)
2348
+ spotify_list_friends(sp_friends)
2349
+ print("─" * HORIZONTAL_LINE)
2350
+ except Exception as e:
2351
+ print(f"* Error: {e}")
2352
+ sys.exit(1)
2353
+ sys.exit(0)
2354
+
2355
+ if not args.user_id:
2356
+ print("* Error: SPOTIFY_USER_URI_ID argument is required !")
2357
+ sys.exit(1)
2358
+
2359
+ if args.monitor_list:
2360
+ MONITOR_LIST_FILE = os.path.expanduser(args.monitor_list)
2361
+ else:
2362
+ if MONITOR_LIST_FILE:
2363
+ MONITOR_LIST_FILE = os.path.expanduser(MONITOR_LIST_FILE)
2364
+
2365
+ if MONITOR_LIST_FILE:
2366
+ try:
2367
+ try:
2368
+ with open(MONITOR_LIST_FILE, encoding="utf-8") as file:
2369
+ lines = file.read().splitlines()
2370
+ except UnicodeDecodeError:
2371
+ with open(MONITOR_LIST_FILE, encoding="cp1252") as file:
2372
+ lines = file.read().splitlines()
2373
+
2374
+ sp_tracks = [
2375
+ line.strip()
2376
+ for line in lines
2377
+ if line.strip() and not line.strip().startswith("#")
2378
+ ]
2379
+ except Exception as e:
2380
+ print(f"* Error: File with monitored Spotify tracks cannot be opened: {e}")
2381
+ sys.exit(1)
2382
+ else:
2383
+ sp_tracks = []
2384
+
2385
+ if args.csv_file:
2386
+ CSV_FILE = os.path.expanduser(args.csv_file)
2387
+ else:
2388
+ if CSV_FILE:
2389
+ CSV_FILE = os.path.expanduser(CSV_FILE)
2390
+
2391
+ if CSV_FILE:
2392
+ try:
2393
+ with open(CSV_FILE, 'a', newline='', buffering=1, encoding="utf-8") as _:
2394
+ pass
2395
+ except Exception as e:
2396
+ print(f"* Error: CSV file cannot be opened for writing: {e}")
2397
+ sys.exit(1)
2398
+
2399
+ if args.file_suffix:
2400
+ FILE_SUFFIX = str(args.file_suffix)
2401
+ else:
2402
+ if not FILE_SUFFIX:
2403
+ FILE_SUFFIX = str(args.user_id)
2404
+
2405
+ if args.disable_logging is True:
2406
+ DISABLE_LOGGING = True
2407
+
2408
+ if not DISABLE_LOGGING:
2409
+ log_path = Path(os.path.expanduser(SP_LOGFILE))
2410
+ if log_path.parent != Path('.'):
2411
+ if log_path.suffix == "":
2412
+ log_path = log_path.parent / f"{log_path.name}_{FILE_SUFFIX}.log"
2413
+ else:
2414
+ if log_path.suffix == "":
2415
+ log_path = Path(f"{log_path.name}_{FILE_SUFFIX}.log")
2416
+ log_path.parent.mkdir(parents=True, exist_ok=True)
2417
+ FINAL_LOG_PATH = str(log_path)
2418
+ sys.stdout = Logger(FINAL_LOG_PATH)
2419
+ else:
2420
+ FINAL_LOG_PATH = None
2421
+
2422
+ if args.notify_active is True:
2423
+ ACTIVE_NOTIFICATION = True
2424
+
2425
+ if args.notify_inactive is True:
2426
+ INACTIVE_NOTIFICATION = True
2427
+
2428
+ if args.notify_track is True:
2429
+ TRACK_NOTIFICATION = True
2430
+
2431
+ if args.notify_song_changes is True:
2432
+ SONG_NOTIFICATION = True
2433
+
2434
+ if args.notify_loop is True:
2435
+ SONG_ON_LOOP_NOTIFICATION = True
2436
+
2437
+ if args.notify_errors is False:
2438
+ ERROR_NOTIFICATION = False
2439
+
2440
+ if args.track_in_spotify is True:
2441
+ TRACK_SONGS = True
2442
+
2443
+ if SMTP_HOST.startswith("your_smtp_server_"):
2444
+ ACTIVE_NOTIFICATION = False
2445
+ INACTIVE_NOTIFICATION = False
2446
+ TRACK_NOTIFICATION = False
2447
+ SONG_NOTIFICATION = False
2448
+ SONG_ON_LOOP_NOTIFICATION = False
2449
+ ERROR_NOTIFICATION = False
2450
+
2451
+ print(f"* Spotify polling intervals:\t[check: {display_time(SPOTIFY_CHECK_INTERVAL)}] [inactivity: {display_time(SPOTIFY_INACTIVITY_CHECK)}]\n\t\t\t\t[disappeared: {display_time(SPOTIFY_DISAPPEARED_CHECK_INTERVAL)}] [error: {display_time(SPOTIFY_ERROR_INTERVAL)}]")
2452
+ 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}]")
2453
+ print(f"* Track listened songs:\t\t{TRACK_SONGS}")
2454
+ print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
2455
+ print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
2456
+ print(f"* Alert on monitored tracks:\t{bool(MONITOR_LIST_FILE)}" + (f" ({MONITOR_LIST_FILE})" if MONITOR_LIST_FILE else ""))
2457
+ print(f"* Output logging enabled:\t{not DISABLE_LOGGING}" + (f" ({FINAL_LOG_PATH})" if not DISABLE_LOGGING else ""))
2458
+ print(f"* Configuration file:\t\t{cfg_path}")
2459
+ print(f"* Dotenv file:\t\t\t{env_path or 'None'}\n")
2460
+
2461
+ # We define signal handlers only for Linux, Unix & MacOS since Windows has limited number of signals supported
2462
+ if platform.system() != 'Windows':
2463
+ signal.signal(signal.SIGUSR1, toggle_active_inactive_notifications_signal_handler)
2464
+ signal.signal(signal.SIGUSR2, toggle_song_notifications_signal_handler)
2465
+ signal.signal(signal.SIGCONT, toggle_track_notifications_signal_handler)
2466
+ signal.signal(signal.SIGPIPE, toggle_songs_on_loop_notifications_signal_handler)
2467
+ signal.signal(signal.SIGTRAP, increase_inactivity_check_signal_handler)
2468
+ signal.signal(signal.SIGABRT, decrease_inactivity_check_signal_handler)
2469
+ signal.signal(signal.SIGHUP, reload_secrets_signal_handler)
2470
+
2471
+ spotify_monitor_friend_uri(args.user_id, sp_tracks, CSV_FILE)
2472
+
2473
+ sys.stdout = stdout_bck
2474
+ sys.exit(0)
2475
+
2476
+
2477
+ if __name__ == "__main__":
2478
+ main()