psn-monitor 1.5__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()
|