psn-monitor 1.5rc4__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 psn-monitor might be problematic. Click here for more details.

psn_monitor.py ADDED
@@ -0,0 +1,1486 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
4
+ v1.5
5
+
6
+ Tool implementing real-time tracking of Sony PlayStation (PSN) players activities:
7
+ https://github.com/misiektoja/psn_monitor/
8
+
9
+ Python pip3 requirements:
10
+
11
+ PSNAWP
12
+ requests
13
+ python-dateutil
14
+ pytz
15
+ tzlocal (optional)
16
+ python-dotenv (optional)
17
+ """
18
+
19
+ VERSION = 1.5
20
+
21
+ # ---------------------------
22
+ # CONFIGURATION SECTION START
23
+ # ---------------------------
24
+
25
+ CONFIG_BLOCK = """
26
+ # Log in to your PSN account:
27
+ # https://my.playstation.com/
28
+ #
29
+ # In another tab, visit:
30
+ # https://ca.account.sony.com/api/v1/ssocookie
31
+ #
32
+ # Copy the value of the npsso code
33
+ #
34
+ # Provide the PSN_NPSSO secret using one of the following methods:
35
+ # - Pass it at runtime with -n / --npsso-key
36
+ # - Set it as an environment variable (e.g. export PSN_NPSSO=...)
37
+ # - Add it to ".env" file (PSN_NPSSO=...) for persistent use
38
+ # Fallback:
39
+ # - Hard-code it in the code or config file
40
+ #
41
+ # The refresh token generated from the npsso should remain valid for about 2 months
42
+ PSN_NPSSO = "your_psn_npsso_code"
43
+
44
+ # SMTP settings for sending email notifications
45
+ # If left as-is, no notifications will be sent
46
+ #
47
+ # Provide the SMTP_PASSWORD secret using one of the following methods:
48
+ # - Set it as an environment variable (e.g. export SMTP_PASSWORD=...)
49
+ # - Add it to ".env" file (SMTP_PASSWORD=...) for persistent use
50
+ # Fallback:
51
+ # - Hard-code it in the code or config file
52
+ SMTP_HOST = "your_smtp_server_ssl"
53
+ SMTP_PORT = 587
54
+ SMTP_USER = "your_smtp_user"
55
+ SMTP_PASSWORD = "your_smtp_password"
56
+ SMTP_SSL = True
57
+ SENDER_EMAIL = "your_sender_email"
58
+ RECEIVER_EMAIL = "your_receiver_email"
59
+
60
+ # Whether to send an email when user goes online/offline
61
+ # Can also be enabled via the -a flag
62
+ ACTIVE_INACTIVE_NOTIFICATION = False
63
+
64
+ # Whether to send an email on game start/change/stop
65
+ # Can also be enabled via the -g flag
66
+ GAME_CHANGE_NOTIFICATION = False
67
+
68
+ # Whether to send an email on errors
69
+ # Can also be disabled via the -e flag
70
+ ERROR_NOTIFICATION = True
71
+
72
+ # How often to check for player activity when the user is offline; in seconds
73
+ # Can also be set using the -c flag
74
+ PSN_CHECK_INTERVAL = 180 # 3 min
75
+
76
+ # How often to check for player activity when the user is online; in seconds
77
+ # Can also be set using the -k flag
78
+ PSN_ACTIVE_CHECK_INTERVAL = 60 # 1 min
79
+
80
+ # Set your local time zone so that PSN API timestamps are converted accordingly (e.g. 'Europe/Warsaw').
81
+ # Use this command to list all time zones supported by pytz:
82
+ # python3 -c "import pytz; print('\\n'.join(pytz.all_timezones))"
83
+ # If set to 'Auto', the tool will try to detect your local time zone automatically (requires tzlocal)
84
+ LOCAL_TIMEZONE = 'Auto'
85
+
86
+ # If the user disconnects (offline) and reconnects (online) within OFFLINE_INTERRUPT seconds,
87
+ # the online session start time will be restored to the previous session's start time (short offline interruption),
88
+ # and previous session statistics (like total playtime and number of played games) will be preserved
89
+ OFFLINE_INTERRUPT = 420 # 7 mins
90
+
91
+ # How often to print a "liveness check" message to the output; in seconds
92
+ # Set to 0 to disable
93
+ LIVENESS_CHECK_INTERVAL = 43200 # 12 hours
94
+
95
+ # URL used to verify internet connectivity at startup
96
+ CHECK_INTERNET_URL = 'https://ca.account.sony.com/'
97
+
98
+ # Timeout used when checking initial internet connectivity; in seconds
99
+ CHECK_INTERNET_TIMEOUT = 5
100
+
101
+ # CSV file to write all status & game changes
102
+ # Can also be set using the -b flag
103
+ CSV_FILE = ""
104
+
105
+ # Location of the optional dotenv file which can keep secrets
106
+ # If not specified it will try to auto-search for .env files
107
+ # To disable auto-search, set this to the literal string "none"
108
+ # Can also be set using the --env-file flag
109
+ DOTENV_FILE = ""
110
+
111
+ # Base name for the log file. Output will be saved to psn_monitor_<psn_user_id>.log
112
+ # Can include a directory path to specify the location, e.g. ~/some_dir/psn_monitor
113
+ PSN_LOGFILE = "psn_monitor"
114
+
115
+ # Whether to disable logging to psn_monitor_<psn_user_id>.log
116
+ # Can also be disabled via the -d flag
117
+ DISABLE_LOGGING = False
118
+
119
+ # Width of horizontal line (─)
120
+ HORIZONTAL_LINE = 113
121
+
122
+ # Whether to clear the terminal screen after starting the tool
123
+ CLEAR_SCREEN = True
124
+
125
+ # Value used by signal handlers increasing/decreasing the check for player activity
126
+ # when user is online (PSN_ACTIVE_CHECK_INTERVAL); in seconds
127
+ PSN_ACTIVE_CHECK_SIGNAL_VALUE = 30 # 30 seconds
128
+ """
129
+
130
+ # -------------------------
131
+ # CONFIGURATION SECTION END
132
+ # -------------------------
133
+
134
+ # Default dummy values so linters shut up
135
+ # Do not change values below - modify them in the configuration section or config file instead
136
+ PSN_NPSSO = ""
137
+ SMTP_HOST = ""
138
+ SMTP_PORT = 0
139
+ SMTP_USER = ""
140
+ SMTP_PASSWORD = ""
141
+ SMTP_SSL = False
142
+ SENDER_EMAIL = ""
143
+ RECEIVER_EMAIL = ""
144
+ ACTIVE_INACTIVE_NOTIFICATION = False
145
+ GAME_CHANGE_NOTIFICATION = False
146
+ ERROR_NOTIFICATION = False
147
+ PSN_CHECK_INTERVAL = 0
148
+ PSN_ACTIVE_CHECK_INTERVAL = 0
149
+ LOCAL_TIMEZONE = ""
150
+ OFFLINE_INTERRUPT = 0
151
+ LIVENESS_CHECK_INTERVAL = 0
152
+ CHECK_INTERNET_URL = ""
153
+ CHECK_INTERNET_TIMEOUT = 0
154
+ CSV_FILE = ""
155
+ DOTENV_FILE = ""
156
+ PSN_LOGFILE = ""
157
+ DISABLE_LOGGING = False
158
+ HORIZONTAL_LINE = 0
159
+ CLEAR_SCREEN = False
160
+ PSN_ACTIVE_CHECK_SIGNAL_VALUE = 0
161
+
162
+ exec(CONFIG_BLOCK, globals())
163
+
164
+ # Default name for the optional config file
165
+ DEFAULT_CONFIG_FILENAME = "psn_monitor.conf"
166
+
167
+ # List of secret keys to load from env/config
168
+ SECRET_KEYS = ("PSN_NPSSO", "SMTP_PASSWORD")
169
+
170
+ # Default value for timeouts in alarm signal handler; in seconds
171
+ FUNCTION_TIMEOUT = 15
172
+
173
+ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / PSN_CHECK_INTERVAL
174
+
175
+ stdout_bck = None
176
+ csvfieldnames = ['Date', 'Status', 'Game name']
177
+
178
+ CLI_CONFIG_PATH = None
179
+
180
+ # to solve the issue: 'SyntaxError: f-string expression part cannot include a backslash'
181
+ nl_ch = "\n"
182
+
183
+
184
+ import sys
185
+
186
+ if sys.version_info < (3, 10):
187
+ print("* Error: Python version 3.10 or higher required !")
188
+ sys.exit(1)
189
+
190
+ import time
191
+ import string
192
+ import json
193
+ import os
194
+ from datetime import datetime, timezone
195
+ from dateutil import relativedelta
196
+ from dateutil.parser import isoparse
197
+ import calendar
198
+ import requests as req
199
+ import signal
200
+ import smtplib
201
+ import ssl
202
+ from email.header import Header
203
+ from email.mime.multipart import MIMEMultipart
204
+ from email.mime.text import MIMEText
205
+ import argparse
206
+ import csv
207
+ try:
208
+ import pytz
209
+ except ModuleNotFoundError:
210
+ raise SystemExit("Error: Couldn't find the pytz library !\n\nTo install it, run:\n pip3 install pytz\n\nOnce installed, re-run this tool")
211
+ try:
212
+ from tzlocal import get_localzone
213
+ except ImportError:
214
+ get_localzone = None
215
+ import platform
216
+ import re
217
+ import ipaddress
218
+ try:
219
+ from psnawp_api import PSNAWP
220
+ from psnawp_api.core.psnawp_exceptions import PSNAWPAuthenticationError
221
+ except ModuleNotFoundError:
222
+ raise SystemExit("Error: Couldn't find the PSNAWP library !\n\nTo install it, run:\n pip3 install PSNAWP\n\nOnce installed, re-run this tool. For more help, visit:\nhttps://github.com/isFakeAccount/psnawp")
223
+ import shutil
224
+ from pathlib import Path
225
+
226
+
227
+ # Logger class to output messages to stdout and log file
228
+ class Logger(object):
229
+ def __init__(self, filename):
230
+ self.terminal = sys.stdout
231
+ self.logfile = open(filename, "a", buffering=1, encoding="utf-8")
232
+
233
+ def write(self, message):
234
+ self.terminal.write(message)
235
+ self.logfile.write(message)
236
+ self.terminal.flush()
237
+ self.logfile.flush()
238
+
239
+ def flush(self):
240
+ pass
241
+
242
+
243
+ # Class used to generate timeout exceptions
244
+ class TimeoutException(Exception):
245
+ pass
246
+
247
+
248
+ # Signal handler for SIGALRM when the operation times out
249
+ def timeout_handler(sig, frame):
250
+ raise TimeoutException
251
+
252
+
253
+ # Signal handler when user presses Ctrl+C
254
+ def signal_handler(sig, frame):
255
+ sys.stdout = stdout_bck
256
+ print('\n* You pressed Ctrl+C, tool is terminated.')
257
+ sys.exit(0)
258
+
259
+
260
+ # Checks internet connectivity
261
+ def check_internet(url=CHECK_INTERNET_URL, timeout=CHECK_INTERNET_TIMEOUT):
262
+ try:
263
+ _ = req.get(url, timeout=timeout)
264
+ return True
265
+ except req.RequestException as e:
266
+ print(f"* No connectivity, please check your network:\n\n{e}")
267
+ return False
268
+
269
+
270
+ # Clears the terminal screen
271
+ def clear_screen(enabled=True):
272
+ if not enabled:
273
+ return
274
+ try:
275
+ if platform.system() == 'Windows':
276
+ os.system('cls')
277
+ else:
278
+ os.system('clear')
279
+ except Exception:
280
+ print("* Cannot clear the screen contents")
281
+
282
+
283
+ # Converts absolute value of seconds to human readable format
284
+ def display_time(seconds, granularity=2):
285
+ intervals = (
286
+ ('years', 31556952), # approximation
287
+ ('months', 2629746), # approximation
288
+ ('weeks', 604800), # 60 * 60 * 24 * 7
289
+ ('days', 86400), # 60 * 60 * 24
290
+ ('hours', 3600), # 60 * 60
291
+ ('minutes', 60),
292
+ ('seconds', 1),
293
+ )
294
+ result = []
295
+
296
+ if seconds > 0:
297
+ for name, count in intervals:
298
+ value = seconds // count
299
+ if value:
300
+ seconds -= value * count
301
+ if value == 1:
302
+ name = name.rstrip('s')
303
+ result.append(f"{value} {name}")
304
+ return ', '.join(result[:granularity])
305
+ else:
306
+ return '0 seconds'
307
+
308
+
309
+ # Calculates time span between two timestamps, accepts timestamp integers, floats and datetime objects
310
+ def calculate_timespan(timestamp1, timestamp2, show_weeks=True, show_hours=True, show_minutes=True, show_seconds=True, granularity=3):
311
+ result = []
312
+ intervals = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds']
313
+ ts1 = timestamp1
314
+ ts2 = timestamp2
315
+
316
+ if isinstance(timestamp1, str):
317
+ try:
318
+ timestamp1 = isoparse(timestamp1)
319
+ except Exception:
320
+ return ""
321
+
322
+ if isinstance(timestamp1, int):
323
+ dt1 = datetime.fromtimestamp(int(ts1), tz=timezone.utc)
324
+ elif isinstance(timestamp1, float):
325
+ ts1 = int(round(ts1))
326
+ dt1 = datetime.fromtimestamp(ts1, tz=timezone.utc)
327
+ elif isinstance(timestamp1, datetime):
328
+ dt1 = timestamp1
329
+ if dt1.tzinfo is None:
330
+ dt1 = pytz.utc.localize(dt1)
331
+ else:
332
+ dt1 = dt1.astimezone(pytz.utc)
333
+ ts1 = int(round(dt1.timestamp()))
334
+ else:
335
+ return ""
336
+
337
+ if isinstance(timestamp2, str):
338
+ try:
339
+ timestamp2 = isoparse(timestamp2)
340
+ except Exception:
341
+ return ""
342
+
343
+ if isinstance(timestamp2, int):
344
+ dt2 = datetime.fromtimestamp(int(ts2), tz=timezone.utc)
345
+ elif isinstance(timestamp2, float):
346
+ ts2 = int(round(ts2))
347
+ dt2 = datetime.fromtimestamp(ts2, tz=timezone.utc)
348
+ elif isinstance(timestamp2, datetime):
349
+ dt2 = timestamp2
350
+ if dt2.tzinfo is None:
351
+ dt2 = pytz.utc.localize(dt2)
352
+ else:
353
+ dt2 = dt2.astimezone(pytz.utc)
354
+ ts2 = int(round(dt2.timestamp()))
355
+ else:
356
+ return ""
357
+
358
+ if ts1 >= ts2:
359
+ ts_diff = ts1 - ts2
360
+ else:
361
+ ts_diff = ts2 - ts1
362
+ dt1, dt2 = dt2, dt1
363
+
364
+ if ts_diff > 0:
365
+ date_diff = relativedelta.relativedelta(dt1, dt2)
366
+ years = date_diff.years
367
+ months = date_diff.months
368
+ days_total = date_diff.days
369
+
370
+ if show_weeks:
371
+ weeks = days_total // 7
372
+ days = days_total % 7
373
+ else:
374
+ weeks = 0
375
+ days = days_total
376
+
377
+ hours = date_diff.hours if show_hours or ts_diff <= 86400 else 0
378
+ minutes = date_diff.minutes if show_minutes or ts_diff <= 3600 else 0
379
+ seconds = date_diff.seconds if show_seconds or ts_diff <= 60 else 0
380
+
381
+ date_list = [years, months, weeks, days, hours, minutes, seconds]
382
+
383
+ for index, interval in enumerate(date_list):
384
+ if interval > 0:
385
+ name = intervals[index]
386
+ if interval == 1:
387
+ name = name.rstrip('s')
388
+ result.append(f"{interval} {name}")
389
+
390
+ return ', '.join(result[:granularity])
391
+ else:
392
+ return '0 seconds'
393
+
394
+
395
+ # Sends email notification
396
+ def send_email(subject, body, body_html, use_ssl, smtp_timeout=15):
397
+ fqdn_re = re.compile(r'(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}\.?$)')
398
+ email_re = re.compile(r'[^@]+@[^@]+\.[^@]+')
399
+
400
+ try:
401
+ ipaddress.ip_address(str(SMTP_HOST))
402
+ except ValueError:
403
+ if not fqdn_re.search(str(SMTP_HOST)):
404
+ print("Error sending email - SMTP settings are incorrect (invalid IP address/FQDN in SMTP_HOST)")
405
+ return 1
406
+
407
+ try:
408
+ port = int(SMTP_PORT)
409
+ if not (1 <= port <= 65535):
410
+ raise ValueError
411
+ except ValueError:
412
+ print("Error sending email - SMTP settings are incorrect (invalid port number in SMTP_PORT)")
413
+ return 1
414
+
415
+ if not email_re.search(str(SENDER_EMAIL)) or not email_re.search(str(RECEIVER_EMAIL)):
416
+ print("Error sending email - SMTP settings are incorrect (invalid email in SENDER_EMAIL or RECEIVER_EMAIL)")
417
+ return 1
418
+
419
+ 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":
420
+ print("Error sending email - SMTP settings are incorrect (check SMTP_USER & SMTP_PASSWORD variables)")
421
+ return 1
422
+
423
+ if not subject or not isinstance(subject, str):
424
+ print("Error sending email - SMTP settings are incorrect (subject is not a string or is empty)")
425
+ return 1
426
+
427
+ if not body and not body_html:
428
+ print("Error sending email - SMTP settings are incorrect (body and body_html cannot be empty at the same time)")
429
+ return 1
430
+
431
+ try:
432
+ if use_ssl:
433
+ ssl_context = ssl.create_default_context()
434
+ smtpObj = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=smtp_timeout)
435
+ smtpObj.starttls(context=ssl_context)
436
+ else:
437
+ smtpObj = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=smtp_timeout)
438
+ smtpObj.login(SMTP_USER, SMTP_PASSWORD)
439
+ email_msg = MIMEMultipart('alternative')
440
+ email_msg["From"] = SENDER_EMAIL
441
+ email_msg["To"] = RECEIVER_EMAIL
442
+ email_msg["Subject"] = str(Header(subject, 'utf-8'))
443
+
444
+ if body:
445
+ part1 = MIMEText(body, 'plain')
446
+ part1 = MIMEText(body.encode('utf-8'), 'plain', _charset='utf-8')
447
+ email_msg.attach(part1)
448
+
449
+ if body_html:
450
+ part2 = MIMEText(body_html, 'html')
451
+ part2 = MIMEText(body_html.encode('utf-8'), 'html', _charset='utf-8')
452
+ email_msg.attach(part2)
453
+
454
+ smtpObj.sendmail(SENDER_EMAIL, RECEIVER_EMAIL, email_msg.as_string())
455
+ smtpObj.quit()
456
+ except Exception as e:
457
+ print(f"Error sending email: {e}")
458
+ return 1
459
+ return 0
460
+
461
+
462
+ # Initializes the CSV file
463
+ def init_csv_file(csv_file_name):
464
+ try:
465
+ if not os.path.isfile(csv_file_name) or os.path.getsize(csv_file_name) == 0:
466
+ with open(csv_file_name, 'a', newline='', buffering=1, encoding="utf-8") as f:
467
+ writer = csv.DictWriter(f, fieldnames=csvfieldnames, quoting=csv.QUOTE_NONNUMERIC)
468
+ writer.writeheader()
469
+ except Exception as e:
470
+ raise RuntimeError(f"Could not initialize CSV file '{csv_file_name}': {e}")
471
+
472
+
473
+ # Writes CSV entry
474
+ def write_csv_entry(csv_file_name, timestamp, status, game_name):
475
+ try:
476
+
477
+ with open(csv_file_name, 'a', newline='', buffering=1, encoding="utf-8") as csv_file:
478
+ csvwriter = csv.DictWriter(csv_file, fieldnames=csvfieldnames, quoting=csv.QUOTE_NONNUMERIC)
479
+ csvwriter.writerow({'Date': timestamp, 'Status': status, 'Game name': game_name})
480
+
481
+ except Exception as e:
482
+ raise RuntimeError(f"Failed to write to CSV file '{csv_file_name}': {e}")
483
+
484
+
485
+ # Returns current local time without timezone info (naive)
486
+ def now_local_naive():
487
+ return datetime.now(pytz.timezone(LOCAL_TIMEZONE)).replace(microsecond=0, tzinfo=None)
488
+
489
+
490
+ # Returns current local time with timezone info (aware)
491
+ def now_local():
492
+ return datetime.now(pytz.timezone(LOCAL_TIMEZONE))
493
+
494
+
495
+ # Converts ISO datetime string to localized datetime (aware)
496
+ def convert_iso_str_to_datetime(dt_str):
497
+ if not dt_str:
498
+ return None
499
+
500
+ try:
501
+ utc_dt = isoparse(dt_str)
502
+ if utc_dt.tzinfo is None:
503
+ utc_dt = pytz.utc.localize(utc_dt)
504
+ return utc_dt.astimezone(pytz.timezone(LOCAL_TIMEZONE))
505
+ except Exception:
506
+ return None
507
+
508
+
509
+ # Returns the current date/time in human readable format; eg. Sun 21 Apr 2024, 15:08:45
510
+ def get_cur_ts(ts_str=""):
511
+ return (f'{ts_str}{calendar.day_abbr[(now_local_naive()).weekday()]}, {now_local_naive().strftime("%d %b %Y, %H:%M:%S")}')
512
+
513
+
514
+ # Prints the current date/time in human readable format with separator; eg. Sun 21 Apr 2024, 15:08:45
515
+ def print_cur_ts(ts_str=""):
516
+ print(get_cur_ts(str(ts_str)))
517
+ print("─" * HORIZONTAL_LINE)
518
+
519
+
520
+ # Returns the timestamp/datetime object in human readable format (long version); eg. Sun 21 Apr 2024, 15:08:45
521
+ def get_date_from_ts(ts):
522
+ tz = pytz.timezone(LOCAL_TIMEZONE)
523
+
524
+ if isinstance(ts, str):
525
+ try:
526
+ ts = isoparse(ts)
527
+ except Exception:
528
+ return ""
529
+
530
+ if isinstance(ts, datetime):
531
+ if ts.tzinfo is None:
532
+ ts = pytz.utc.localize(ts)
533
+ ts_new = ts.astimezone(tz)
534
+
535
+ elif isinstance(ts, int):
536
+ ts_new = datetime.fromtimestamp(ts, tz)
537
+
538
+ elif isinstance(ts, float):
539
+ ts_rounded = int(round(ts))
540
+ ts_new = datetime.fromtimestamp(ts_rounded, tz)
541
+
542
+ else:
543
+ return ""
544
+
545
+ return (f'{calendar.day_abbr[ts_new.weekday()]} {ts_new.strftime("%d %b %Y, %H:%M:%S")}')
546
+
547
+
548
+ # Returns the timestamp/datetime object in human readable format (short version); eg.
549
+ # Sun 21 Apr 15:08
550
+ # Sun 21 Apr 24, 15:08 (if show_year == True and current year is different)
551
+ # Sun 21 Apr 25, 15:08 (if always_show_year == True and current year can be the same)
552
+ # Sun 21 Apr (if show_hour == False)
553
+ # Sun 21 Apr 15:08:32 (if show_seconds == True)
554
+ # 21 Apr 15:08 (if show_weekday == False)
555
+ def get_short_date_from_ts(ts, show_year=False, show_hour=True, show_weekday=True, show_seconds=False, always_show_year=False):
556
+ tz = pytz.timezone(LOCAL_TIMEZONE)
557
+ if always_show_year:
558
+ show_year = True
559
+
560
+ if isinstance(ts, str):
561
+ try:
562
+ ts = isoparse(ts)
563
+ except Exception:
564
+ return ""
565
+
566
+ if isinstance(ts, datetime):
567
+ if ts.tzinfo is None:
568
+ ts = pytz.utc.localize(ts)
569
+ ts_new = ts.astimezone(tz)
570
+
571
+ elif isinstance(ts, int):
572
+ ts_new = datetime.fromtimestamp(ts, tz)
573
+
574
+ elif isinstance(ts, float):
575
+ ts_rounded = int(round(ts))
576
+ ts_new = datetime.fromtimestamp(ts_rounded, tz)
577
+
578
+ else:
579
+ return ""
580
+
581
+ if show_hour:
582
+ hour_strftime = " %H:%M:%S" if show_seconds else " %H:%M"
583
+ else:
584
+ hour_strftime = ""
585
+
586
+ weekday_str = f"{calendar.day_abbr[ts_new.weekday()]} " if show_weekday else ""
587
+
588
+ if (show_year and ts_new.year != datetime.now(tz).year) or always_show_year:
589
+ hour_prefix = "," if show_hour else ""
590
+ return f'{weekday_str}{ts_new.strftime(f"%d %b %y{hour_prefix}{hour_strftime}")}'
591
+ else:
592
+ return f'{weekday_str}{ts_new.strftime(f"%d %b{hour_strftime}")}'
593
+
594
+
595
+ # Returns the timestamp/datetime object in human readable format (only hour, minutes and optionally seconds): eg. 15:08:12
596
+ def get_hour_min_from_ts(ts, show_seconds=False):
597
+ tz = pytz.timezone(LOCAL_TIMEZONE)
598
+
599
+ if isinstance(ts, str):
600
+ try:
601
+ ts = isoparse(ts)
602
+ except Exception:
603
+ return ""
604
+
605
+ if isinstance(ts, datetime):
606
+ if ts.tzinfo is None:
607
+ ts = pytz.utc.localize(ts)
608
+ ts_new = ts.astimezone(tz)
609
+
610
+ elif isinstance(ts, int):
611
+ ts_new = datetime.fromtimestamp(ts, tz)
612
+
613
+ elif isinstance(ts, float):
614
+ ts_rounded = int(round(ts))
615
+ ts_new = datetime.fromtimestamp(ts_rounded, tz)
616
+
617
+ else:
618
+ return ""
619
+
620
+ out_strf = "%H:%M:%S" if show_seconds else "%H:%M"
621
+ return ts_new.strftime(out_strf)
622
+
623
+
624
+ # Returns the range between two timestamps/datetime objects; eg. Sun 21 Apr 14:09 - 14:15
625
+ def get_range_of_dates_from_tss(ts1, ts2, between_sep=" - ", short=False):
626
+ tz = pytz.timezone(LOCAL_TIMEZONE)
627
+
628
+ if isinstance(ts1, datetime):
629
+ ts1_new = int(round(ts1.timestamp()))
630
+ elif isinstance(ts1, int):
631
+ ts1_new = ts1
632
+ elif isinstance(ts1, float):
633
+ ts1_new = int(round(ts1))
634
+ else:
635
+ return ""
636
+
637
+ if isinstance(ts2, datetime):
638
+ ts2_new = int(round(ts2.timestamp()))
639
+ elif isinstance(ts2, int):
640
+ ts2_new = ts2
641
+ elif isinstance(ts2, float):
642
+ ts2_new = int(round(ts2))
643
+ else:
644
+ return ""
645
+
646
+ ts1_strf = datetime.fromtimestamp(ts1_new, tz).strftime("%Y%m%d")
647
+ ts2_strf = datetime.fromtimestamp(ts2_new, tz).strftime("%Y%m%d")
648
+
649
+ if ts1_strf == ts2_strf:
650
+ if short:
651
+ out_str = f"{get_short_date_from_ts(ts1_new)}{between_sep}{get_hour_min_from_ts(ts2_new)}"
652
+ else:
653
+ out_str = f"{get_date_from_ts(ts1_new)}{between_sep}{get_hour_min_from_ts(ts2_new, show_seconds=True)}"
654
+ else:
655
+ if short:
656
+ out_str = f"{get_short_date_from_ts(ts1_new)}{between_sep}{get_short_date_from_ts(ts2_new)}"
657
+ else:
658
+ out_str = f"{get_date_from_ts(ts1_new)}{between_sep}{get_date_from_ts(ts2_new)}"
659
+
660
+ return str(out_str)
661
+
662
+
663
+ # Checks if the timezone name is correct
664
+ def is_valid_timezone(tz_name):
665
+ return tz_name in pytz.all_timezones
666
+
667
+
668
+ # Signal handler for SIGUSR1 allowing to switch active/inactive email notifications
669
+ def toggle_active_inactive_notifications_signal_handler(sig, frame):
670
+ global ACTIVE_INACTIVE_NOTIFICATION
671
+ ACTIVE_INACTIVE_NOTIFICATION = not ACTIVE_INACTIVE_NOTIFICATION
672
+ sig_name = signal.Signals(sig).name
673
+ print(f"* Signal {sig_name} received")
674
+ print(f"* Email notifications: [active/inactive status changes = {ACTIVE_INACTIVE_NOTIFICATION}]")
675
+ print_cur_ts("Timestamp:\t\t\t")
676
+
677
+
678
+ # Signal handler for SIGUSR2 allowing to switch played game changes notifications
679
+ def toggle_game_change_notifications_signal_handler(sig, frame):
680
+ global GAME_CHANGE_NOTIFICATION
681
+ GAME_CHANGE_NOTIFICATION = not GAME_CHANGE_NOTIFICATION
682
+ sig_name = signal.Signals(sig).name
683
+ print(f"* Signal {sig_name} received")
684
+ print(f"* Email notifications: [game changes = {GAME_CHANGE_NOTIFICATION}]")
685
+ print_cur_ts("Timestamp:\t\t\t")
686
+
687
+
688
+ # Signal handler for SIGTRAP allowing to increase check timer for player activity when user is online by PSN_ACTIVE_CHECK_SIGNAL_VALUE seconds
689
+ def increase_active_check_signal_handler(sig, frame):
690
+ global PSN_ACTIVE_CHECK_INTERVAL
691
+ PSN_ACTIVE_CHECK_INTERVAL = PSN_ACTIVE_CHECK_INTERVAL + PSN_ACTIVE_CHECK_SIGNAL_VALUE
692
+ sig_name = signal.Signals(sig).name
693
+ print(f"* Signal {sig_name} received")
694
+ print(f"* PSN timers: [active check interval: {display_time(PSN_ACTIVE_CHECK_INTERVAL)}]")
695
+ print_cur_ts("Timestamp:\t\t\t")
696
+
697
+
698
+ # Signal handler for SIGABRT allowing to decrease check timer for player activity when user is online by PSN_ACTIVE_CHECK_SIGNAL_VALUE seconds
699
+ def decrease_active_check_signal_handler(sig, frame):
700
+ global PSN_ACTIVE_CHECK_INTERVAL
701
+ if PSN_ACTIVE_CHECK_INTERVAL - PSN_ACTIVE_CHECK_SIGNAL_VALUE > 0:
702
+ PSN_ACTIVE_CHECK_INTERVAL = PSN_ACTIVE_CHECK_INTERVAL - PSN_ACTIVE_CHECK_SIGNAL_VALUE
703
+ sig_name = signal.Signals(sig).name
704
+ print(f"* Signal {sig_name} received")
705
+ print(f"* PSN timers: [active check interval: {display_time(PSN_ACTIVE_CHECK_INTERVAL)}]")
706
+ print_cur_ts("Timestamp:\t\t\t")
707
+
708
+
709
+ # Signal handler for SIGHUP allowing to reload secrets from .env
710
+ def reload_secrets_signal_handler(sig, frame):
711
+ sig_name = signal.Signals(sig).name
712
+ print(f"* Signal {sig_name} received")
713
+
714
+ # disable autoscan if DOTENV_FILE set to none
715
+ if DOTENV_FILE and DOTENV_FILE.lower() == 'none':
716
+ env_path = None
717
+ else:
718
+ # reload .env if python-dotenv is installed
719
+ try:
720
+ from dotenv import load_dotenv, find_dotenv
721
+ if DOTENV_FILE:
722
+ env_path = DOTENV_FILE
723
+ else:
724
+ env_path = find_dotenv()
725
+ if env_path:
726
+ load_dotenv(env_path, override=True)
727
+ else:
728
+ print("* No .env file found, skipping env-var reload")
729
+ except ImportError:
730
+ env_path = None
731
+ print("* python-dotenv not installed, skipping env-var reload")
732
+
733
+ if env_path:
734
+ for secret in SECRET_KEYS:
735
+ old_val = globals().get(secret)
736
+ val = os.getenv(secret)
737
+ if val is not None and val != old_val:
738
+ globals()[secret] = val
739
+ print(f"* Reloaded {secret} from {env_path}")
740
+
741
+ print_cur_ts("Timestamp:\t\t\t")
742
+
743
+
744
+ # Finds an optional config file
745
+ def find_config_file(cli_path=None):
746
+ """
747
+ Search for an optional config file in:
748
+ 1) CLI-provided path (must exist if given)
749
+ 2) ./{DEFAULT_CONFIG_FILENAME}
750
+ 3) ~/.{DEFAULT_CONFIG_FILENAME}
751
+ 4) script-directory/{DEFAULT_CONFIG_FILENAME}
752
+ """
753
+
754
+ if cli_path:
755
+ p = Path(os.path.expanduser(cli_path))
756
+ return str(p) if p.is_file() else None
757
+
758
+ candidates = [
759
+ Path.cwd() / DEFAULT_CONFIG_FILENAME,
760
+ Path.home() / f".{DEFAULT_CONFIG_FILENAME}",
761
+ Path(__file__).parent / DEFAULT_CONFIG_FILENAME,
762
+ ]
763
+
764
+ for p in candidates:
765
+ if p.is_file():
766
+ return str(p)
767
+ return None
768
+
769
+
770
+ # Resolves an executable path by checking if it's a valid file or searching in $PATH
771
+ def resolve_executable(path):
772
+ if os.path.isfile(path) and os.access(path, os.X_OK):
773
+ return path
774
+
775
+ found = shutil.which(path)
776
+ if found:
777
+ return found
778
+
779
+ raise FileNotFoundError(f"Could not find executable '{path}'")
780
+
781
+
782
+ # Main function that monitors gaming activity of the specified PSN user
783
+ def psn_monitor_user(psn_user_id, csv_file_name):
784
+
785
+ alive_counter = 0
786
+ status_ts = 0
787
+ status_ts_old = 0
788
+ status_online_start_ts = 0
789
+ status_online_start_ts_old = 0
790
+ game_ts = 0
791
+ game_ts_old = 0
792
+ lastonline_ts = 0
793
+ status = ""
794
+ game_total_ts = 0
795
+ games_number = 0
796
+ game_total_after_offline_counted = False
797
+
798
+ try:
799
+ if csv_file_name:
800
+ init_csv_file(csv_file_name)
801
+ except Exception as e:
802
+ print(f"* Error: {e}")
803
+
804
+ try:
805
+ psnawp = PSNAWP(PSN_NPSSO)
806
+ psn_user = psnawp.user(online_id=psn_user_id)
807
+ accountid = psn_user.account_id
808
+ profile = psn_user.profile()
809
+ aboutme = profile.get("aboutMe")
810
+ isplus = profile.get("isPlus")
811
+ except Exception as e:
812
+ print("* Error:", e)
813
+ sys.exit(1)
814
+
815
+ try:
816
+ psn_user_presence = psn_user.get_presence()
817
+ except Exception as e:
818
+ print(f"* Error: Cannot get presence for user {psn_user_id}: {e}")
819
+ sys.exit(1)
820
+
821
+ status = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("onlineStatus")
822
+
823
+ if not status:
824
+ print(f"* Error: Cannot get status for user {psn_user_id}")
825
+ sys.exit(1)
826
+
827
+ status = str(status).lower()
828
+
829
+ psn_platform = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("platform")
830
+ psn_platform = str(psn_platform).upper()
831
+ lastonline = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("lastOnlineDate")
832
+
833
+ lastonline_dt = convert_iso_str_to_datetime(lastonline)
834
+ if lastonline_dt:
835
+ lastonline_ts = int(lastonline_dt.timestamp())
836
+ else:
837
+ lastonline_ts = 0
838
+ gametitleinfolist = psn_user_presence["basicPresence"].get("gameTitleInfoList")
839
+ game_name = ""
840
+ launchplatform = ""
841
+ if gametitleinfolist:
842
+ game_name = gametitleinfolist[0].get("titleName")
843
+ launchplatform = gametitleinfolist[0].get("launchPlatform")
844
+ launchplatform = str(launchplatform).upper()
845
+
846
+ status_ts_old = int(time.time())
847
+ status_ts_old_bck = status_ts_old
848
+
849
+ if status and status != "offline":
850
+ status_online_start_ts = status_ts_old
851
+ status_online_start_ts_old = status_online_start_ts
852
+
853
+ psn_last_status_file = f"psn_{psn_user_id}_last_status.json"
854
+ last_status_read = []
855
+ last_status_ts = 0
856
+ last_status = ""
857
+
858
+ if os.path.isfile(psn_last_status_file):
859
+ try:
860
+ with open(psn_last_status_file, 'r', encoding="utf-8") as f:
861
+ last_status_read = json.load(f)
862
+ except Exception as e:
863
+ print(f"* Cannot load last status from '{psn_last_status_file}' file: {e}")
864
+ if last_status_read:
865
+ last_status_ts = last_status_read[0]
866
+ last_status = last_status_read[1]
867
+ psn_last_status_file_mdate_dt = datetime.fromtimestamp(int(os.path.getmtime(psn_last_status_file)), pytz.timezone(LOCAL_TIMEZONE))
868
+
869
+ print(f"* Last status loaded from file '{psn_last_status_file}' ({get_short_date_from_ts(psn_last_status_file_mdate_dt, show_weekday=False, always_show_year=True)})")
870
+
871
+ if last_status_ts > 0:
872
+ last_status_dt_str = get_short_date_from_ts(last_status_ts, show_weekday=False, always_show_year=True)
873
+ last_status_str = str(last_status.upper())
874
+ print(f"* Last status read from file: {last_status_str} ({last_status_dt_str})")
875
+
876
+ if lastonline_ts and status == "offline":
877
+ if lastonline_ts >= last_status_ts:
878
+ status_ts_old = lastonline_ts
879
+ else:
880
+ status_ts_old = last_status_ts
881
+ if not lastonline_ts and status == "offline":
882
+ status_ts_old = last_status_ts
883
+ if status and status != "offline" and status == last_status:
884
+ status_online_start_ts = last_status_ts
885
+ status_online_start_ts_old = status_online_start_ts
886
+ status_ts_old = last_status_ts
887
+
888
+ if last_status_ts > 0 and status != last_status:
889
+ last_status_to_save = []
890
+ last_status_to_save.append(status_ts_old)
891
+ last_status_to_save.append(status)
892
+ try:
893
+ with open(psn_last_status_file, 'w', encoding="utf-8") as f:
894
+ json.dump(last_status_to_save, f, indent=2)
895
+ except Exception as e:
896
+ print(f"* Cannot save last status to '{psn_last_status_file}' file: {e}")
897
+
898
+ try:
899
+ if csv_file_name and (status != last_status):
900
+ write_csv_entry(csv_file_name, now_local_naive(), status, game_name)
901
+ except Exception as e:
902
+ print(f"* Error: {e}")
903
+
904
+ print(f"\nPlayStation ID:\t\t\t{psn_user_id}")
905
+ print(f"PSN account ID:\t\t\t{accountid}")
906
+
907
+ print(f"\nStatus:\t\t\t\t{str(status).upper()}")
908
+ if psn_platform:
909
+ print(f"Platform:\t\t\t{psn_platform}")
910
+ print(f"PS+ user:\t\t\t{isplus}")
911
+
912
+ if aboutme:
913
+ print(f"\nAbout me:\t\t\t{aboutme}")
914
+
915
+ if status != "offline" and game_name:
916
+ launchplatform_str = ""
917
+ if launchplatform:
918
+ launchplatform_str = f" ({launchplatform})"
919
+ print(f"\nUser is currently in-game:\t{game_name}{launchplatform_str}")
920
+ game_ts_old = int(time.time())
921
+ games_number += 1
922
+
923
+ if last_status_ts == 0:
924
+ if lastonline_ts and status == "offline":
925
+ status_ts_old = lastonline_ts
926
+ last_status_to_save = []
927
+ last_status_to_save.append(status_ts_old)
928
+ last_status_to_save.append(status)
929
+ try:
930
+ with open(psn_last_status_file, 'w', encoding="utf-8") as f:
931
+ json.dump(last_status_to_save, f, indent=2)
932
+ except Exception as e:
933
+ print(f"* Cannot save last status to '{psn_last_status_file}' file: {e}")
934
+
935
+ if status_ts_old != status_ts_old_bck:
936
+ if status == "offline":
937
+ last_status_dt_str = get_date_from_ts(status_ts_old)
938
+ print(f"\n* Last time user was available:\t{last_status_dt_str}")
939
+ print(f"\n* User is {str(status).upper()} for:\t\t{calculate_timespan(now_local(), int(status_ts_old), show_seconds=False)}")
940
+
941
+ status_old = status
942
+ game_name_old = game_name
943
+
944
+ print_cur_ts("\nTimestamp:\t\t\t")
945
+
946
+ alive_counter = 0
947
+ email_sent = False
948
+
949
+ m_subject = m_body = ""
950
+
951
+ def get_sleep_interval():
952
+ return PSN_ACTIVE_CHECK_INTERVAL if status and status != "offline" else PSN_CHECK_INTERVAL
953
+
954
+ sleep_interval = get_sleep_interval()
955
+
956
+ time.sleep(sleep_interval)
957
+
958
+ # Main loop
959
+ while True:
960
+ # Sometimes PSN network functions halt, so we use alarm signal functionality to kill it inevitably, not available on Windows
961
+ if platform.system() != 'Windows':
962
+ signal.signal(signal.SIGALRM, timeout_handler)
963
+ signal.alarm(FUNCTION_TIMEOUT)
964
+ try:
965
+ psn_user_presence = psn_user.get_presence()
966
+ status = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("onlineStatus")
967
+ gametitleinfolist = psn_user_presence["basicPresence"].get("gameTitleInfoList")
968
+ game_name = ""
969
+ launchplatform = ""
970
+ if gametitleinfolist:
971
+ game_name = gametitleinfolist[0].get("titleName")
972
+ launchplatform = gametitleinfolist[0].get("launchPlatform")
973
+ launchplatform = str(launchplatform).upper()
974
+ if platform.system() != 'Windows':
975
+ signal.alarm(0)
976
+ if not status:
977
+ raise ValueError('PSN user status is empty')
978
+ else:
979
+ status = str(status).lower()
980
+ except TimeoutException:
981
+ if platform.system() != 'Windows':
982
+ signal.alarm(0)
983
+ print(f"psn_user.get_presence() timeout, retrying in {display_time(FUNCTION_TIMEOUT)}")
984
+ print_cur_ts("Timestamp:\t\t\t")
985
+ time.sleep(FUNCTION_TIMEOUT)
986
+ continue
987
+
988
+ except PSNAWPAuthenticationError as auth_err:
989
+ if platform.system() != 'Windows':
990
+ signal.alarm(0)
991
+ sleep_interval = get_sleep_interval()
992
+ print(f"* PSN NPSSO key might not be valid anymore: {auth_err}")
993
+ if ERROR_NOTIFICATION and not email_sent:
994
+ m_subject = f"psn_monitor: PSN NPSSO key error! (user: {psn_user_id})"
995
+ m_body = f"PSN NPSSO key might not be valid anymore: {auth_err}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
996
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
997
+ send_email(m_subject, m_body, "", SMTP_SSL)
998
+ email_sent = True
999
+ print_cur_ts("Timestamp:\t\t\t")
1000
+ time.sleep(sleep_interval)
1001
+ continue
1002
+
1003
+ except Exception as e:
1004
+ if platform.system() != 'Windows':
1005
+ signal.alarm(0)
1006
+
1007
+ if 'Remote end closed connection' in str(e):
1008
+ try:
1009
+ psnawp = PSNAWP(PSN_NPSSO)
1010
+ psn_user = psnawp.user(online_id=psn_user_id)
1011
+ except Exception:
1012
+ pass
1013
+ time.sleep(FUNCTION_TIMEOUT)
1014
+ continue
1015
+
1016
+ sleep_interval = get_sleep_interval()
1017
+ print(f"* Error, retrying in {display_time(sleep_interval)}: {e}")
1018
+ print_cur_ts("Timestamp:\t\t\t")
1019
+ time.sleep(sleep_interval)
1020
+ continue
1021
+
1022
+ else:
1023
+ email_sent = False
1024
+
1025
+ finally:
1026
+ if platform.system() != 'Windows':
1027
+ signal.alarm(0)
1028
+
1029
+ change = False
1030
+ act_inact_flag = False
1031
+
1032
+ status_ts = int(time.time())
1033
+ game_ts = int(time.time())
1034
+
1035
+ # Player status changed
1036
+ if status != status_old:
1037
+
1038
+ last_status_to_save = []
1039
+ last_status_to_save.append(status_ts)
1040
+ last_status_to_save.append(status)
1041
+ try:
1042
+ with open(psn_last_status_file, 'w', encoding="utf-8") as f:
1043
+ json.dump(last_status_to_save, f, indent=2)
1044
+ except Exception as e:
1045
+ print(f"* Cannot save last status to '{psn_last_status_file}' file: {e}")
1046
+
1047
+ print(f"PSN user {psn_user_id} changed status from {status_old} to {status}")
1048
+ print(f"User was {status_old} for {calculate_timespan(int(status_ts), int(status_ts_old))} ({get_range_of_dates_from_tss(int(status_ts_old), int(status_ts), short=True)})")
1049
+
1050
+ m_subject_was_since = f", was {status_old}: {get_range_of_dates_from_tss(int(status_ts_old), int(status_ts), short=True)}"
1051
+ m_subject_after = calculate_timespan(int(status_ts), int(status_ts_old), show_seconds=False)
1052
+ m_body_was_since = f" ({get_range_of_dates_from_tss(int(status_ts_old), int(status_ts), short=True)})"
1053
+
1054
+ m_body_short_offline_msg = ""
1055
+
1056
+ # Player got online
1057
+ if status_old == "offline" and status and status != "offline":
1058
+ print(f"*** User got ACTIVE ! (was offline since {get_date_from_ts(status_ts_old)})")
1059
+ game_total_after_offline_counted = False
1060
+ if (status_ts - status_ts_old) > OFFLINE_INTERRUPT or not status_online_start_ts_old:
1061
+ status_online_start_ts = status_ts
1062
+ game_total_ts = 0
1063
+ games_number = 0
1064
+ elif (status_ts - status_ts_old) <= OFFLINE_INTERRUPT and status_online_start_ts_old > 0:
1065
+ status_online_start_ts = status_online_start_ts_old
1066
+ short_offline_msg = f"Short offline interruption ({display_time(status_ts - status_ts_old)}), online start timestamp set back to {get_short_date_from_ts(status_online_start_ts_old)}"
1067
+ m_body_short_offline_msg = f"\n\n{short_offline_msg}"
1068
+ print(short_offline_msg)
1069
+ act_inact_flag = True
1070
+
1071
+ m_body_played_games = ""
1072
+
1073
+ # Player got offline
1074
+ if status_old and status_old != "offline" and status == "offline":
1075
+ if status_online_start_ts > 0:
1076
+ m_subject_after = calculate_timespan(int(status_ts), int(status_online_start_ts), show_seconds=False)
1077
+ online_since_msg = f"(after {calculate_timespan(int(status_ts), int(status_online_start_ts), show_seconds=False)}: {get_range_of_dates_from_tss(int(status_online_start_ts), int(status_ts), short=True)})"
1078
+ m_subject_was_since = f", was available: {get_range_of_dates_from_tss(int(status_online_start_ts), int(status_ts), short=True)}"
1079
+ m_body_was_since = f" ({get_range_of_dates_from_tss(int(status_ts_old), int(status_ts), short=True)})\n\nUser was available for {calculate_timespan(int(status_ts), int(status_online_start_ts), show_seconds=False)} ({get_range_of_dates_from_tss(int(status_online_start_ts), int(status_ts), short=True)})"
1080
+ else:
1081
+ online_since_msg = ""
1082
+ if games_number > 0:
1083
+ if game_name_old and not game_name:
1084
+ game_total_ts += (int(game_ts) - int(game_ts_old))
1085
+ game_total_after_offline_counted = True
1086
+ m_body_played_games = f"\n\nUser played {games_number} games for total time of {display_time(game_total_ts)}"
1087
+ print(f"User played {games_number} games for total time of {display_time(game_total_ts)}")
1088
+ print(f"*** User got OFFLINE ! {online_since_msg}")
1089
+ status_online_start_ts_old = status_online_start_ts
1090
+ status_online_start_ts = 0
1091
+ act_inact_flag = True
1092
+
1093
+ m_body_user_in_game = ""
1094
+ if status != "offline" and game_name:
1095
+ launchplatform_str = ""
1096
+ if launchplatform:
1097
+ launchplatform_str = f" ({launchplatform})"
1098
+ print(f"User is currently in-game: {game_name}{launchplatform_str}")
1099
+ m_body_user_in_game = f"\n\nUser is currently in-game: {game_name}{launchplatform_str}"
1100
+
1101
+ change = True
1102
+
1103
+ m_subject = f"PSN user {psn_user_id} is now {status} (after {m_subject_after}{m_subject_was_since})"
1104
+ m_body = f"PSN user {psn_user_id} changed status from {status_old} to {status}\n\nUser was {status_old} for {calculate_timespan(int(status_ts), int(status_ts_old))}{m_body_was_since}{m_body_short_offline_msg}{m_body_user_in_game}{m_body_played_games}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1105
+ if ACTIVE_INACTIVE_NOTIFICATION and act_inact_flag:
1106
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1107
+ send_email(m_subject, m_body, "", SMTP_SSL)
1108
+
1109
+ status_ts_old = status_ts
1110
+ print_cur_ts("Timestamp:\t\t\t")
1111
+
1112
+ # Player started/stopped/changed the game
1113
+ if game_name != game_name_old:
1114
+
1115
+ launchplatform_str = ""
1116
+ if launchplatform:
1117
+ launchplatform_str = f" ({launchplatform})"
1118
+
1119
+ # User changed the game
1120
+ if game_name_old and game_name:
1121
+ print(f"PSN user {psn_user_id} changed game from '{game_name_old}' to '{game_name}'{launchplatform_str} after {calculate_timespan(int(game_ts), int(game_ts_old))}")
1122
+ print(f"User played game from {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True, between_sep=' to ')}")
1123
+ game_total_ts += (int(game_ts) - int(game_ts_old))
1124
+ games_number += 1
1125
+ m_body = f"PSN user {psn_user_id} changed game from '{game_name_old}' to '{game_name}'{launchplatform_str} after {calculate_timespan(int(game_ts), int(game_ts_old))}\n\nUser played game from {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True, between_sep=' to ')}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1126
+ if launchplatform:
1127
+ launchplatform_str = f"{launchplatform}, "
1128
+ m_subject = f"PSN user {psn_user_id} changed game to '{game_name}' ({launchplatform_str}after {calculate_timespan(int(game_ts), int(game_ts_old), show_seconds=False)}: {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True)})"
1129
+
1130
+ # User started playing new game
1131
+ elif not game_name_old and game_name:
1132
+ print(f"PSN user {psn_user_id} started playing '{game_name}'{launchplatform_str}")
1133
+ games_number += 1
1134
+ m_subject = f"PSN user {psn_user_id} now plays '{game_name}'{launchplatform_str}"
1135
+ m_body = f"PSN user {psn_user_id} now plays '{game_name}'{launchplatform_str}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1136
+
1137
+ # User stopped playing the game
1138
+ elif game_name_old and not game_name:
1139
+ print(f"PSN user {psn_user_id} stopped playing '{game_name_old}' after {calculate_timespan(int(game_ts), int(game_ts_old))}")
1140
+ print(f"User played game from {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True, between_sep=' to ')}")
1141
+ if not game_total_after_offline_counted:
1142
+ game_total_ts += (int(game_ts) - int(game_ts_old))
1143
+ m_subject = f"PSN user {psn_user_id} stopped playing '{game_name_old}' (after {calculate_timespan(int(game_ts), int(game_ts_old), show_seconds=False)}: {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True)})"
1144
+ m_body = f"PSN user {psn_user_id} stopped playing '{game_name_old}' after {calculate_timespan(int(game_ts), int(game_ts_old))}\n\nUser played game from {get_range_of_dates_from_tss(int(game_ts_old), int(game_ts), short=True, between_sep=' to ')}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1145
+
1146
+ change = True
1147
+
1148
+ if GAME_CHANGE_NOTIFICATION and m_subject and m_body:
1149
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1150
+ send_email(m_subject, m_body, "", SMTP_SSL)
1151
+
1152
+ game_ts_old = game_ts
1153
+ print_cur_ts("Timestamp:\t\t\t")
1154
+
1155
+ if change:
1156
+ alive_counter = 0
1157
+
1158
+ try:
1159
+ if csv_file_name:
1160
+ write_csv_entry(csv_file_name, now_local_naive(), status, game_name)
1161
+ except Exception as e:
1162
+ print(f"* Error: {e}")
1163
+
1164
+ status_old = status
1165
+ game_name_old = game_name
1166
+ alive_counter += 1
1167
+
1168
+ if LIVENESS_CHECK_COUNTER and alive_counter >= LIVENESS_CHECK_COUNTER and (status == "offline" or not status):
1169
+ print_cur_ts("Liveness check, timestamp:\t")
1170
+ alive_counter = 0
1171
+
1172
+ sleep_interval = get_sleep_interval()
1173
+ time.sleep(sleep_interval)
1174
+
1175
+
1176
+ def main():
1177
+ global CLI_CONFIG_PATH, DOTENV_FILE, LOCAL_TIMEZONE, LIVENESS_CHECK_COUNTER, PSN_NPSSO, CSV_FILE, DISABLE_LOGGING, PSN_LOGFILE, ACTIVE_INACTIVE_NOTIFICATION, GAME_CHANGE_NOTIFICATION, ERROR_NOTIFICATION, PSN_CHECK_INTERVAL, PSN_ACTIVE_CHECK_INTERVAL, SMTP_PASSWORD, stdout_bck
1178
+
1179
+ if "--generate-config" in sys.argv:
1180
+ print(CONFIG_BLOCK.strip("\n"))
1181
+ sys.exit(0)
1182
+
1183
+ if "--version" in sys.argv:
1184
+ print(f"{os.path.basename(sys.argv[0])} v{VERSION}")
1185
+ sys.exit(0)
1186
+
1187
+ stdout_bck = sys.stdout
1188
+
1189
+ signal.signal(signal.SIGINT, signal_handler)
1190
+ signal.signal(signal.SIGTERM, signal_handler)
1191
+
1192
+ clear_screen(CLEAR_SCREEN)
1193
+
1194
+ print(f"PSN Monitoring Tool v{VERSION}\n")
1195
+
1196
+ parser = argparse.ArgumentParser(
1197
+ prog="psn_monitor",
1198
+ description=("Monitor a PSN user's playing status and send customizable email alerts [ https://github.com/misiektoja/psn_monitor/ ]"), formatter_class=argparse.RawTextHelpFormatter
1199
+ )
1200
+
1201
+ # Positional
1202
+ parser.add_argument(
1203
+ "psn_user_id",
1204
+ nargs="?",
1205
+ metavar="PSN_USER_ID",
1206
+ help="User's PSN ID",
1207
+ type=str
1208
+ )
1209
+
1210
+ # Version, just to list in help, it is handled earlier
1211
+ parser.add_argument(
1212
+ "--version",
1213
+ action="version",
1214
+ version=f"%(prog)s v{VERSION}"
1215
+ )
1216
+
1217
+ # Configuration & dotenv files
1218
+ conf = parser.add_argument_group("Configuration & dotenv files")
1219
+ conf.add_argument(
1220
+ "--config-file",
1221
+ dest="config_file",
1222
+ metavar="PATH",
1223
+ help="Location of the optional config file",
1224
+ )
1225
+ conf.add_argument(
1226
+ "--generate-config",
1227
+ action="store_true",
1228
+ help="Print default config template and exit",
1229
+ )
1230
+ conf.add_argument(
1231
+ "--env-file",
1232
+ dest="env_file",
1233
+ metavar="PATH",
1234
+ help="Path to optional dotenv file (auto-search if not set, disable with 'none')",
1235
+ )
1236
+
1237
+ # API credentials
1238
+ creds = parser.add_argument_group("API credentials")
1239
+ creds.add_argument(
1240
+ "-n", "--npsso-key",
1241
+ dest="npsso_key",
1242
+ metavar="PSN_NPSSO",
1243
+ type=str,
1244
+ help="PlayStation NPSSO key"
1245
+ )
1246
+
1247
+ # Notifications
1248
+ notify = parser.add_argument_group("Notifications")
1249
+ notify.add_argument(
1250
+ "-a", "--notify-active-inactive",
1251
+ dest="notify_active_inactive",
1252
+ action="store_true",
1253
+ default=None,
1254
+ help="Email when user goes online/offline"
1255
+ )
1256
+ notify.add_argument(
1257
+ "-g", "--notify-game-change",
1258
+ dest="notify_game_change",
1259
+ action="store_true",
1260
+ default=None,
1261
+ help="Email on game start/change/stop"
1262
+ )
1263
+ notify.add_argument(
1264
+ "-e", "--no-error-notify",
1265
+ dest="notify_errors",
1266
+ action="store_false",
1267
+ default=None,
1268
+ help="Disable email on errors (e.g. invalid NPSSO)"
1269
+ )
1270
+ notify.add_argument(
1271
+ "--send-test-email",
1272
+ dest="send_test_email",
1273
+ action="store_true",
1274
+ help="Send test email to verify SMTP settings"
1275
+ )
1276
+
1277
+ # Intervals & timers
1278
+ times = parser.add_argument_group("Intervals & timers")
1279
+ times.add_argument(
1280
+ "-c", "--check-interval",
1281
+ dest="check_interval",
1282
+ metavar="SECONDS",
1283
+ type=int,
1284
+ help="Polling interval when user is offline"
1285
+ )
1286
+ times.add_argument(
1287
+ "-k", "--active-interval",
1288
+ dest="active_interval",
1289
+ metavar="SECONDS",
1290
+ type=int,
1291
+ help="Polling interval when user is online"
1292
+ )
1293
+
1294
+ # Features & Output
1295
+ opts = parser.add_argument_group("Features & output")
1296
+ opts.add_argument(
1297
+ "-b", "--csv-file",
1298
+ dest="csv_file",
1299
+ metavar="CSV_FILENAME",
1300
+ type=str,
1301
+ help="Write status & game changes to CSV"
1302
+ )
1303
+ opts.add_argument(
1304
+ "-d", "--disable-logging",
1305
+ dest="disable_logging",
1306
+ action="store_true",
1307
+ default=None,
1308
+ help="Disable logging to psn_monitor_<psn_user_id>.log"
1309
+ )
1310
+
1311
+ args = parser.parse_args()
1312
+
1313
+ if len(sys.argv) == 1:
1314
+ parser.print_help(sys.stderr)
1315
+ sys.exit(1)
1316
+
1317
+ if args.config_file:
1318
+ CLI_CONFIG_PATH = os.path.expanduser(args.config_file)
1319
+
1320
+ cfg_path = find_config_file(CLI_CONFIG_PATH)
1321
+
1322
+ if not cfg_path and CLI_CONFIG_PATH:
1323
+ print(f"* Error: Config file '{CLI_CONFIG_PATH}' does not exist")
1324
+ sys.exit(1)
1325
+
1326
+ if cfg_path:
1327
+ try:
1328
+ with open(cfg_path, "r") as cf:
1329
+ exec(cf.read(), globals())
1330
+ except Exception as e:
1331
+ print(f"* Error loading config file '{cfg_path}': {e}")
1332
+ sys.exit(1)
1333
+
1334
+ if args.env_file:
1335
+ DOTENV_FILE = os.path.expanduser(args.env_file)
1336
+ else:
1337
+ if DOTENV_FILE:
1338
+ DOTENV_FILE = os.path.expanduser(DOTENV_FILE)
1339
+
1340
+ if DOTENV_FILE and DOTENV_FILE.lower() == 'none':
1341
+ env_path = None
1342
+ else:
1343
+ try:
1344
+ from dotenv import load_dotenv, find_dotenv
1345
+
1346
+ if DOTENV_FILE:
1347
+ env_path = DOTENV_FILE
1348
+ if not os.path.isfile(env_path):
1349
+ print(f"* Warning: dotenv file '{env_path}' does not exist\n")
1350
+ else:
1351
+ load_dotenv(env_path, override=True)
1352
+ else:
1353
+ env_path = find_dotenv() or None
1354
+ if env_path:
1355
+ load_dotenv(env_path, override=True)
1356
+ except ImportError:
1357
+ env_path = DOTENV_FILE if DOTENV_FILE else None
1358
+ if env_path:
1359
+ 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")
1360
+
1361
+ if env_path:
1362
+ for secret in SECRET_KEYS:
1363
+ val = os.getenv(secret)
1364
+ if val is not None:
1365
+ globals()[secret] = val
1366
+
1367
+ local_tz = None
1368
+ if LOCAL_TIMEZONE == "Auto":
1369
+ if get_localzone is not None:
1370
+ try:
1371
+ local_tz = get_localzone()
1372
+ except Exception:
1373
+ pass
1374
+ if local_tz:
1375
+ LOCAL_TIMEZONE = str(local_tz)
1376
+ else:
1377
+ print("* Error: Cannot detect local timezone, consider setting LOCAL_TIMEZONE to your local timezone manually !")
1378
+ sys.exit(1)
1379
+ else:
1380
+ if not is_valid_timezone(LOCAL_TIMEZONE):
1381
+ print(f"* Error: Configured LOCAL_TIMEZONE '{LOCAL_TIMEZONE}' is not valid. Please use a valid pytz timezone name.")
1382
+ sys.exit(1)
1383
+
1384
+ if not check_internet():
1385
+ sys.exit(1)
1386
+
1387
+ if args.send_test_email:
1388
+ print("* Sending test email notification ...\n")
1389
+ if send_email("psn_monitor: test email", "This is test email - your SMTP settings seems to be correct !", "", SMTP_SSL, smtp_timeout=5) == 0:
1390
+ print("* Email sent successfully !")
1391
+ else:
1392
+ sys.exit(1)
1393
+ sys.exit(0)
1394
+
1395
+ if not args.psn_user_id:
1396
+ print("* Error: PSN_USER_ID needs to be defined !")
1397
+ sys.exit(1)
1398
+
1399
+ if args.npsso_key:
1400
+ PSN_NPSSO = args.npsso_key
1401
+
1402
+ if not PSN_NPSSO or PSN_NPSSO == "your_psn_npsso_code":
1403
+ print("* Error: PSN_NPSSO (-n / --npsso_key) value is empty or incorrect")
1404
+ sys.exit(1)
1405
+
1406
+ if args.check_interval:
1407
+ PSN_CHECK_INTERVAL = args.check_interval
1408
+ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / PSN_CHECK_INTERVAL
1409
+
1410
+ if args.active_interval:
1411
+ PSN_ACTIVE_CHECK_INTERVAL = args.active_interval
1412
+
1413
+ if args.csv_file:
1414
+ CSV_FILE = os.path.expanduser(args.csv_file)
1415
+ else:
1416
+ if CSV_FILE:
1417
+ CSV_FILE = os.path.expanduser(CSV_FILE)
1418
+
1419
+ if CSV_FILE:
1420
+ try:
1421
+ with open(CSV_FILE, 'a', newline='', buffering=1, encoding="utf-8") as _:
1422
+ pass
1423
+ except Exception as e:
1424
+ print(f"* Error, CSV file cannot be opened for writing: {e}")
1425
+ sys.exit(1)
1426
+
1427
+ if args.disable_logging is True:
1428
+ DISABLE_LOGGING = True
1429
+
1430
+ if not DISABLE_LOGGING:
1431
+ log_path = Path(os.path.expanduser(PSN_LOGFILE))
1432
+ if log_path.parent != Path('.'):
1433
+ if log_path.suffix == "":
1434
+ log_path = log_path.parent / f"{log_path.name}_{args.psn_user_id}.log"
1435
+ else:
1436
+ if log_path.suffix == "":
1437
+ log_path = Path(f"{log_path.name}_{args.psn_user_id}.log")
1438
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1439
+ FINAL_LOG_PATH = str(log_path)
1440
+ sys.stdout = Logger(FINAL_LOG_PATH)
1441
+ else:
1442
+ FINAL_LOG_PATH = None
1443
+
1444
+ if args.notify_active_inactive is True:
1445
+ ACTIVE_INACTIVE_NOTIFICATION = True
1446
+
1447
+ if args.notify_game_change is True:
1448
+ GAME_CHANGE_NOTIFICATION = True
1449
+
1450
+ if args.notify_errors is False:
1451
+ ERROR_NOTIFICATION = False
1452
+
1453
+ if SMTP_HOST.startswith("your_smtp_server_"):
1454
+ ACTIVE_INACTIVE_NOTIFICATION = False
1455
+ GAME_CHANGE_NOTIFICATION = False
1456
+ ERROR_NOTIFICATION = False
1457
+
1458
+ print(f"* PSN polling intervals:\t[offline: {display_time(PSN_CHECK_INTERVAL)}] [online: {display_time(PSN_ACTIVE_CHECK_INTERVAL)}]")
1459
+ print(f"* Email notifications:\t\t[online/offline status changes = {ACTIVE_INACTIVE_NOTIFICATION}] [game changes = {GAME_CHANGE_NOTIFICATION}]\n*\t\t\t\t[errors = {ERROR_NOTIFICATION}]")
1460
+ print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
1461
+ print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
1462
+ print(f"* Output logging enabled:\t{not DISABLE_LOGGING}" + (f" ({FINAL_LOG_PATH})" if not DISABLE_LOGGING else ""))
1463
+ print(f"* Configuration file:\t\t{cfg_path}")
1464
+ print(f"* Dotenv file:\t\t\t{env_path or 'None'}")
1465
+ print(f"* Local timezone:\t\t{LOCAL_TIMEZONE}")
1466
+
1467
+ out = f"\nMonitoring user with PSN ID {args.psn_user_id}"
1468
+ print(out)
1469
+ print("-" * len(out))
1470
+
1471
+ # We define signal handlers only for Linux, Unix & MacOS since Windows has limited number of signals supported
1472
+ if platform.system() != 'Windows':
1473
+ signal.signal(signal.SIGUSR1, toggle_active_inactive_notifications_signal_handler)
1474
+ signal.signal(signal.SIGUSR2, toggle_game_change_notifications_signal_handler)
1475
+ signal.signal(signal.SIGTRAP, increase_active_check_signal_handler)
1476
+ signal.signal(signal.SIGABRT, decrease_active_check_signal_handler)
1477
+ signal.signal(signal.SIGHUP, reload_secrets_signal_handler)
1478
+
1479
+ psn_monitor_user(args.psn_user_id, CSV_FILE)
1480
+
1481
+ sys.stdout = stdout_bck
1482
+ sys.exit(0)
1483
+
1484
+
1485
+ if __name__ == "__main__":
1486
+ main()