psn-monitor 1.5.1__py3-none-any.whl → 1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: psn_monitor
3
- Version: 1.5.1
3
+ Version: 1.6
4
4
  Summary: Tool implementing real-time tracking of Sony PlayStation (PSN) players activities
5
5
  Author-email: Michal Szymanski <misiektoja-pypi@rm-rf.ninja>
6
6
  License-Expression: GPL-3.0-or-later
@@ -32,13 +32,15 @@ psn_monitor is a tool for real-time monitoring of Sony PlayStation (PSN) players
32
32
  ## Features
33
33
 
34
34
  - Real-time tracking of PlayStation users' gaming activity (including detection when a user gets online/offline or plays games)
35
+ - Detailed user information display mode providing comprehensive PlayStation profile insights
35
36
  - Basic statistics for user activity (duration in different states, time spent playing a game, overall time and number of games played in a session etc.)
36
37
  - Email notifications for various events (player gets online/offline, starts/finishes/changes a game, errors)
37
38
  - Saving all user activities with timestamps to a CSV file
38
39
  - Possibility to control the running copy of the script via signals
40
+ - Functional, procedural Python (minimal OOP)
39
41
 
40
42
  <p align="center">
41
- <img src="https://raw.githubusercontent.com/misiektoja/psn_monitor/refs/heads/main/assets/psn_monitor.png" alt="psn_monitor_screenshot" width="85%"/>
43
+ <img src="https://raw.githubusercontent.com/misiektoja/psn_monitor/refs/heads/main/assets/psn_monitor.png" alt="psn_monitor_screenshot" width="90%"/>
42
44
  </p>
43
45
 
44
46
  <a id="table-of-contents"></a>
@@ -48,6 +50,7 @@ psn_monitor is a tool for real-time monitoring of Sony PlayStation (PSN) players
48
50
  2. [Installation](#installation)
49
51
  * [Install from PyPI](#install-from-pypi)
50
52
  * [Manual Installation](#manual-installation)
53
+ * [Upgrading](#upgrading)
51
54
  3. [Quick Start](#quick-start)
52
55
  4. [Configuration](#configuration)
53
56
  * [Configuration File](#configuration-file)
@@ -57,6 +60,7 @@ psn_monitor is a tool for real-time monitoring of Sony PlayStation (PSN) players
57
60
  * [SMTP Settings](#smtp-settings)
58
61
  * [Storing Secrets](#storing-secrets)
59
62
  5. [Usage](#usage)
63
+ * [User Information Display Mode](#user-information-display-mode)
60
64
  * [Monitoring Mode](#monitoring-mode)
61
65
  * [Email Notifications](#email-notifications)
62
66
  * [CSV Export](#csv-export)
@@ -74,8 +78,8 @@ psn_monitor is a tool for real-time monitoring of Sony PlayStation (PSN) players
74
78
 
75
79
  Tested on:
76
80
 
77
- * **macOS**: Ventura, Sonoma, Sequoia
78
- * **Linux**: Raspberry Pi OS (Bullseye, Bookworm), Ubuntu 24, Rocky Linux 8.x/9.x, Kali Linux 2024/2025
81
+ * **macOS**: Ventura, Sonoma, Sequoia, Tahoe
82
+ * **Linux**: Raspberry Pi OS (Bullseye, Bookworm, Trixie), Ubuntu 24/25, Rocky Linux 8.x/9.x, Kali Linux 2024/2025
79
83
  * **Windows**: 10, 11
80
84
 
81
85
  It should work on other versions of macOS, Linux, Unix and Windows as well.
@@ -107,6 +111,17 @@ Alternatively, from the downloaded *[requirements.txt](https://raw.githubusercon
107
111
  pip install -r requirements.txt
108
112
  ```
109
113
 
114
+ <a id="upgrading"></a>
115
+ ### Upgrading
116
+
117
+ To upgrade to the latest version when installed from PyPI:
118
+
119
+ ```sh
120
+ pip install psn_monitor -U
121
+ ```
122
+
123
+ If you installed manually, download the newest *[psn_monitor.py](https://raw.githubusercontent.com/misiektoja/psn_monitor/refs/heads/main/psn_monitor.py)* file to replace your existing installation.
124
+
110
125
  <a id="quick-start"></a>
111
126
  ## Quick Start
112
127
 
@@ -241,6 +256,51 @@ As a fallback, you can also store secrets in the configuration file or source co
241
256
  <a id="usage"></a>
242
257
  ## Usage
243
258
 
259
+ <a id="user-information-display-mode"></a>
260
+ ### User Information Display Mode
261
+
262
+ The tool provides a detailed user information display mode that shows comprehensive PlayStation profile insights. This mode displays information once and then exits (it does not run continuous monitoring).
263
+
264
+ To get detailed user information, use the `-i` or `--info` flag:
265
+
266
+ ```sh
267
+ psn_monitor <psn_user_id> -i
268
+ ```
269
+
270
+ This displays:
271
+ - PlayStation/PSN IDs
272
+ - Online status and availability to play
273
+ - Platform information
274
+ - PS+ subscription status
275
+ - Verification status
276
+ - About me section
277
+ - Languages
278
+ - Friendship relation and mutual friends count
279
+ - Profile URL
280
+ - Recently played games with last played date and total play time
281
+
282
+ To also display trophy summary and list of most recently earned trophies, add the `--trophies` flag:
283
+
284
+ ```sh
285
+ psn_monitor <psn_user_id> -i --trophies
286
+ ```
287
+
288
+ To disable fetching the recently played games list (faster execution), use the `--no-recent-games` flag:
289
+
290
+ ```sh
291
+ psn_monitor <psn_user_id> -i --no-recent-games
292
+ ```
293
+
294
+ You can combine both flags:
295
+
296
+ ```sh
297
+ psn_monitor <psn_user_id> -i --trophies --no-recent-games
298
+ ```
299
+
300
+ <p align="center">
301
+ <img src="https://raw.githubusercontent.com/misiektoja/psn_monitor/refs/heads/main/assets/psn_monitor_info.png" alt="psn_monitor_info" width="90%"/>
302
+ </p>
303
+
244
304
  <a id="monitoring-mode"></a>
245
305
  ### Monitoring Mode
246
306
 
@@ -0,0 +1,7 @@
1
+ psn_monitor.py,sha256=LUOMdhCCK5V0fx-2bkW0SfqrLjywOYc6-IwON6SNZg8,78993
2
+ psn_monitor-1.6.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ psn_monitor-1.6.dist-info/METADATA,sha256=yf8X9sbc4ocZF5i85LkmN7Jxn7QBriTkqyFf1wGEFCU,14458
4
+ psn_monitor-1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ psn_monitor-1.6.dist-info/entry_points.txt,sha256=2yXV06LBmzhWnvc4bKuEblezaWSHEWKdB2HClGXZlnk,49
6
+ psn_monitor-1.6.dist-info/top_level.txt,sha256=IG37NL5yiB0wgx_MN-L47SDxKdqRXzjIqBpkU7JiISE,12
7
+ psn_monitor-1.6.dist-info/RECORD,,
psn_monitor.py CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
4
- v1.5.1
4
+ v1.6
5
5
 
6
6
  Tool implementing real-time tracking of Sony PlayStation (PSN) players activities:
7
7
  https://github.com/misiektoja/psn_monitor/
@@ -16,7 +16,7 @@ tzlocal (optional)
16
16
  python-dotenv (optional)
17
17
  """
18
18
 
19
- VERSION = "1.5.1"
19
+ VERSION = "1.6"
20
20
 
21
21
  # ---------------------------
22
22
  # CONFIGURATION SECTION START
@@ -779,6 +779,496 @@ def resolve_executable(path):
779
779
  raise FileNotFoundError(f"Could not find executable '{path}'")
780
780
 
781
781
 
782
+ # Normalizes Unicode punctuation, symbols and spacing in a string to plain ASCII
783
+ def normalize_ascii(s):
784
+ if not isinstance(s, str):
785
+ return s
786
+ # punctuation & symbols to ASCII
787
+ s = (s.replace("\u2018", "'").replace("\u2019", "'") # ‘ ’ -> '
788
+ .replace("\u201C", '"').replace("\u201D", '"') # “ ” -> "
789
+ .replace("\u2013", "-") # – -> -
790
+ .replace("\u2026", "...") # … -> ...
791
+ .replace("\u00A0", " ")) # NBSP -> space
792
+ # remove trademark symbols
793
+ for ch in ("\u00AE", "\u2122"): # ® ™
794
+ s = s.replace(ch, "")
795
+ # collapse doubled single quotes that often appear after smart-quote normalization
796
+ s = s.replace("''", "'")
797
+ # collapse multiple spaces
798
+ while " " in s:
799
+ s = s.replace(" ", " ")
800
+ return s.strip()
801
+
802
+
803
+ # Prints the last N earned trophies across titles with game, type and earn date
804
+ def print_last_earned_trophies(psn_user, max_items=5, title_limit=15):
805
+ PT = None
806
+ try:
807
+ from psnawp_api.models.trophies import PlatformType as PT # 3.x
808
+ except Exception:
809
+ PT = None # fallback to string platforms later
810
+
811
+ def _get(obj, *names, default=None):
812
+ for n in names:
813
+ if hasattr(obj, n):
814
+ v = getattr(obj, n)
815
+ if v is not None:
816
+ return v
817
+ return default
818
+
819
+ def _platforms_to_try(title):
820
+ raw = getattr(title, "platform", None)
821
+ raw_val = getattr(raw, "value", raw)
822
+ s = (str(raw_val).lower() if raw_val else "")
823
+ if PT:
824
+ if "ps5" in s:
825
+ return [PT.PS5, PT.PS4]
826
+ if "ps4" in s:
827
+ return [PT.PS4, PT.PS5]
828
+ return [PT.PS5, PT.PS4]
829
+ # string fallback
830
+ if "ps5" in s:
831
+ return ["ps5", "ps4"]
832
+ if "ps4" in s:
833
+ return ["ps4", "ps5"]
834
+ return ["ps5", "ps4"]
835
+
836
+ def _earn_dt(tr):
837
+ return _get(tr, "earned_date_time", "earnedDateTime", default=None)
838
+
839
+ def _trophy_type_str(tr):
840
+ raw = _get(tr, "trophy_type", "trophyType", default=None)
841
+ if raw is None:
842
+ return "UNKNOWN"
843
+ if hasattr(raw, "name"):
844
+ return raw.name
845
+ return str(raw).upper()
846
+
847
+ # title-name resolver (cache)
848
+ _title_name_cache = {}
849
+
850
+ def _resolve_title_name(npcomm, platform):
851
+ key = (npcomm, str(platform))
852
+ if key in _title_name_cache:
853
+ return _title_name_cache[key]
854
+
855
+ def _first_name_like(obj):
856
+ # Try common fields first
857
+ for fld in ("trophy_title_name", "trophyTitleName", "title_name", "titleName", "name"):
858
+ if hasattr(obj, fld):
859
+ val = getattr(obj, fld)
860
+ if isinstance(val, str) and val.strip():
861
+ return val.strip()
862
+ # Fallback: scan attributes that look like "*name"
863
+ for attr in dir(obj):
864
+ if attr.startswith("_"):
865
+ continue
866
+ if "name" in attr.lower():
867
+ try:
868
+ val = getattr(obj, attr)
869
+ except Exception:
870
+ continue
871
+ if isinstance(val, str) and val.strip():
872
+ return val.strip()
873
+ return None
874
+
875
+ name = None
876
+
877
+ # A) groups often carry the title name
878
+ try:
879
+ for g in psn_user.trophy_groups(np_communication_id=npcomm, platform=platform):
880
+ name = _first_name_like(g)
881
+ if name:
882
+ break
883
+ except Exception:
884
+ pass
885
+
886
+ # B) per-title summary
887
+ if not name:
888
+ try:
889
+ summ = psn_user.trophy_summary(np_communication_id=npcomm, platform=platform)
890
+ name = _first_name_like(summ)
891
+ except Exception:
892
+ pass
893
+
894
+ # C) scan titles
895
+ if not name:
896
+ try:
897
+ for tt in psn_user.trophy_titles(limit=title_limit):
898
+ nc = getattr(tt, "np_communication_id", None) or getattr(tt, "npCommunicationId", None)
899
+ if nc == npcomm:
900
+ name = _first_name_like(tt)
901
+ if name:
902
+ break
903
+ except Exception:
904
+ pass
905
+
906
+ if not name:
907
+ name = npcomm # last resort
908
+
909
+ _title_name_cache[key] = name
910
+ return name
911
+ # -------------------------------------
912
+
913
+ items = []
914
+
915
+ # 1) list titles (no special args for cross-version compat)
916
+ try:
917
+ titles_iter = psn_user.trophy_titles(limit=title_limit)
918
+ except Exception:
919
+ titles_iter = []
920
+
921
+ for tt in titles_iter:
922
+ npcomm = _get(tt, "np_communication_id", "npCommunicationId", default=None)
923
+ if not npcomm:
924
+ continue
925
+
926
+ for plat in _platforms_to_try(tt):
927
+ try:
928
+ it = psn_user.trophies(
929
+ np_communication_id=npcomm,
930
+ platform=plat,
931
+ include_progress=True,
932
+ trophy_group_id="all",
933
+ )
934
+ except Exception:
935
+ continue
936
+
937
+ got_any_for_title = False
938
+ for tr in it:
939
+ got_any_for_title = True
940
+ if not getattr(tr, "earned", False):
941
+ continue
942
+ dt = _earn_dt(tr)
943
+ if not dt:
944
+ continue
945
+
946
+ game_name = normalize_ascii(_resolve_title_name(npcomm, plat))
947
+ ttype = _trophy_type_str(tr)
948
+ tname = _get(tr, "trophy_name", "trophyName", default=None)
949
+ if not tname:
950
+ tname = "(hidden)" if getattr(tr, "hidden", False) else "(unknown)"
951
+ tname = normalize_ascii(tname)
952
+
953
+ items.append((dt, game_name, ttype, tname))
954
+
955
+ if got_any_for_title:
956
+ break # this platform works for this title
957
+
958
+ if len(items) >= max_items:
959
+ break
960
+
961
+ if not items:
962
+ print("- (no recent trophies found or trophy visibility is restricted)")
963
+ return
964
+
965
+ # 2) sort & print
966
+ try:
967
+ items.sort(key=lambda x: x[0], reverse=True)
968
+ except Exception:
969
+ def _ts(dt):
970
+ try:
971
+ return int(dt.timestamp())
972
+ except Exception:
973
+ return -1
974
+ items.sort(key=lambda x: _ts(x[0]), reverse=True)
975
+
976
+ for dt, game, ttype, tname in items[:max_items]:
977
+ try:
978
+ ts = int(dt.timestamp())
979
+ dt_fmt = get_date_from_ts(ts)
980
+ except Exception:
981
+ dt_fmt = "n/a"
982
+ print(f"- {dt_fmt} | {game} | {ttype} | {tname}")
983
+
984
+
985
+ # Gets detailed user information and displays it (for -i/--info mode)
986
+ def get_user_info(psn_user_id, include_trophies=False, show_recent_games=True):
987
+ print(f"* Fetching details for PlayStation user '{psn_user_id}'... this may take a moment\n")
988
+
989
+ try:
990
+ psnawp = PSNAWP(PSN_NPSSO)
991
+ psn_user = psnawp.user(online_id=psn_user_id)
992
+ accountid = psn_user.account_id
993
+ profile = psn_user.profile()
994
+ aboutme = profile.get("aboutMe")
995
+ isplus = profile.get("isPlus")
996
+ langs = profile.get("languages") or []
997
+ is_verified = profile.get("isOfficiallyVerified")
998
+ fs = psn_user.friendship()
999
+ share = psn_user.get_shareable_profile_link()
1000
+ except Exception as e:
1001
+ print(f"* Error: {e}")
1002
+ sys.exit(1)
1003
+
1004
+ try:
1005
+ psn_user_presence = psn_user.get_presence()
1006
+ except Exception as e:
1007
+ print(f"* Error: Cannot get presence for user {psn_user_id}: {e}")
1008
+ sys.exit(1)
1009
+
1010
+ status = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("onlineStatus")
1011
+
1012
+ if not status:
1013
+ print(f"* Error: Cannot get status for user {psn_user_id}")
1014
+ sys.exit(1)
1015
+
1016
+ status = str(status).lower()
1017
+
1018
+ psn_platform = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("platform")
1019
+ psn_platform = str(psn_platform).upper() if psn_platform else ""
1020
+ lastonline = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("lastOnlineDate")
1021
+ availability = psn_user_presence["basicPresence"].get("availability")
1022
+
1023
+ lastonline_dt = convert_iso_str_to_datetime(lastonline)
1024
+ if lastonline_dt:
1025
+ lastonline_ts = int(lastonline_dt.timestamp())
1026
+ else:
1027
+ lastonline_ts = 0
1028
+
1029
+ gametitleinfolist = psn_user_presence["basicPresence"].get("gameTitleInfoList")
1030
+ game_name = ""
1031
+ launchplatform = ""
1032
+
1033
+ if gametitleinfolist:
1034
+ game_name_raw = gametitleinfolist[0].get("titleName")
1035
+ game_name = normalize_ascii(game_name_raw) if game_name_raw else ""
1036
+ launchplatform = gametitleinfolist[0].get("launchPlatform")
1037
+ launchplatform = str(launchplatform).upper()
1038
+
1039
+ psn_last_status_file = f"psn_{psn_user_id}_last_status.json"
1040
+ status_ts_old = int(time.time())
1041
+
1042
+ if os.path.isfile(psn_last_status_file):
1043
+ try:
1044
+ with open(psn_last_status_file, 'r', encoding="utf-8") as f:
1045
+ last_status_read = json.load(f)
1046
+ if last_status_read:
1047
+ last_status_ts = last_status_read[0]
1048
+ last_status = last_status_read[1]
1049
+
1050
+ if lastonline_ts and status == "offline":
1051
+ if lastonline_ts >= last_status_ts:
1052
+ status_ts_old = lastonline_ts
1053
+ else:
1054
+ status_ts_old = last_status_ts
1055
+ elif not lastonline_ts and status == "offline":
1056
+ status_ts_old = last_status_ts
1057
+ elif status and status != "offline" and status == last_status:
1058
+ status_ts_old = last_status_ts
1059
+ except Exception:
1060
+ if lastonline_ts and status == "offline":
1061
+ status_ts_old = lastonline_ts
1062
+ else:
1063
+ if lastonline_ts and status == "offline":
1064
+ status_ts_old = lastonline_ts
1065
+
1066
+ print(f"PlayStation ID:\t\t\t{psn_user_id}")
1067
+ print(f"PSN account ID:\t\t\t{accountid}")
1068
+ print(f"\nStatus:\t\t\t\t{str(status).upper()}")
1069
+ if availability:
1070
+ available_str = "Yes" if availability == "availableToPlay" else "No"
1071
+ print(f"Available to play:\t\t{available_str}")
1072
+
1073
+ psn_platform_displayed = False
1074
+ if psn_platform:
1075
+ print(f"\nPlatform:\t\t\t{psn_platform}")
1076
+ psn_platform_displayed = True
1077
+
1078
+ if not psn_platform_displayed:
1079
+ print()
1080
+ print(f"PS+ user:\t\t\t{isplus}")
1081
+
1082
+ # an official account belonging to a recognised developer, publisher, community manager or another official role
1083
+ if is_verified is not None:
1084
+ print(f"Verified:\t\t\t{is_verified}")
1085
+
1086
+ newline_needed = False
1087
+
1088
+ if aboutme:
1089
+ print(f"\nAbout me:\t\t\t{aboutme}")
1090
+ newline_needed = True
1091
+
1092
+ if langs:
1093
+ prefix = "\n" if not newline_needed else ""
1094
+ print(f"{prefix}Languages:\t\t\t{', '.join(langs)}")
1095
+
1096
+ try:
1097
+ relation = fs.get("friendRelation")
1098
+ print(f"\nRelation:\t\t\t{relation}")
1099
+
1100
+ if relation == "friend":
1101
+ mf = fs.get("mutualFriendsCount")
1102
+ if isinstance(mf, int) and mf >= 0:
1103
+ print(f"Mutual friends:\t\t\t{mf}")
1104
+ elif mf is None:
1105
+ print("Mutual friends:\t\t\tunknown")
1106
+ else:
1107
+ print("Mutual friends:\t\t\thidden")
1108
+ else:
1109
+ # Don't print mutual friends at all
1110
+ pass
1111
+
1112
+ except Exception:
1113
+ pass
1114
+
1115
+ try:
1116
+ print(f"\nProfile URL:\t\t\t{share.get('shareUrl')}")
1117
+ # print(f"Profile QR image:\t\t{share.get('shareImageUrl')}")
1118
+ except Exception:
1119
+ pass
1120
+
1121
+ if status == "offline" and status_ts_old > 0:
1122
+ last_status_dt_str = get_date_from_ts(status_ts_old)
1123
+ print(f"\n* Last time user was available:\t{last_status_dt_str}")
1124
+ print(f"* User is OFFLINE for:\t\t{calculate_timespan(now_local(), int(status_ts_old), show_seconds=False)}")
1125
+ elif status != "offline":
1126
+ if os.path.isfile(psn_last_status_file):
1127
+ try:
1128
+ with open(psn_last_status_file, 'r', encoding="utf-8") as f:
1129
+ last_status_read = json.load(f)
1130
+ if last_status_read and last_status_read[1] == status:
1131
+ print(f"* User is {str(status).upper()} for:\t\t{calculate_timespan(now_local(), int(last_status_read[0]), show_seconds=False)}")
1132
+ except Exception:
1133
+ pass
1134
+
1135
+ # Show trophy summary and last earned trophies only if requested
1136
+ if include_trophies:
1137
+ try:
1138
+ print(f"\n* Getting trophy summary ...")
1139
+ ts = psn_user.trophy_summary()
1140
+ et = ts.earned_trophies
1141
+ prog = int(ts.progress) if ts.progress is not None else 0
1142
+ print(f"\nTrophy level:\t\t\t{ts.trophy_level} ({prog}% to next, tier {ts.tier})")
1143
+ print(
1144
+ "Trophies earned:\t\t"
1145
+ f"{et.platinum} Platinum, {et.gold} Gold, {et.silver} Silver, {et.bronze} Bronze "
1146
+ f"({et.platinum + et.gold + et.silver + et.bronze} total)"
1147
+ )
1148
+ except Exception:
1149
+ pass
1150
+
1151
+ num_trophies = 5
1152
+ try:
1153
+ print(f"\n* Getting list of last {num_trophies} earned trophies ...\n")
1154
+ print_last_earned_trophies(psn_user, max_items=num_trophies, title_limit=15)
1155
+ except Exception:
1156
+ pass
1157
+
1158
+ # Show recently played games only if requested
1159
+ if show_recent_games:
1160
+ try:
1161
+ # Helper function to compact duration format, convert "X day(s), HH:MM:SS" to "Xd HH:MM:SS"
1162
+ def _compact_duration(s):
1163
+ if not s:
1164
+ return "0:00:00"
1165
+ s = str(s).strip()
1166
+
1167
+ if "day" in s.lower():
1168
+ try:
1169
+ if "," in s:
1170
+ parts = s.split(",", 1)
1171
+ days_part = parts[0].strip() # "1 day" / "2 days"
1172
+ time_part = parts[1].strip() # "23:47:54"
1173
+ d = int(days_part.split()[0])
1174
+ return f"{d}d {time_part}"
1175
+ else:
1176
+ # No comma, try to extract days anyway (unlikely but handle it)
1177
+ words = s.split()
1178
+ if len(words) >= 2 and words[1].lower().startswith("day"):
1179
+ d = int(words[0])
1180
+ if len(words) > 2:
1181
+ time_part = " ".join(words[2:])
1182
+ return f"{d}d {time_part}"
1183
+ return f"{d}d"
1184
+ except (ValueError, IndexError):
1185
+ return s # fallback to original if parsing fails
1186
+ return s
1187
+
1188
+ def _shorten_middle(s, max_len, ellipsis="..."):
1189
+ if s is None:
1190
+ return ""
1191
+ s = str(s)
1192
+ if len(s) <= max_len:
1193
+ return s
1194
+ keep = max_len - len(ellipsis)
1195
+ if keep <= 0:
1196
+ return ellipsis[:max_len]
1197
+ left = keep // 2
1198
+ right = keep - left
1199
+ return f"{s[:left]}{ellipsis}{s[-right:]}"
1200
+
1201
+ recent_entries = []
1202
+ print(f"\n* Getting list of recently played games ...")
1203
+ for i, t in enumerate(psn_user.title_stats(limit=10, page_size=50), 1):
1204
+ if not t:
1205
+ continue
1206
+ name_raw = t.name or "(unknown)"
1207
+ name = normalize_ascii(name_raw)
1208
+ cat = getattr(getattr(t, "category", None), "name", "UNKNOWN")
1209
+ last_played = (
1210
+ get_date_from_ts(int(t.last_played_date_time.timestamp()))
1211
+ if t.last_played_date_time else "n/a"
1212
+ )
1213
+ total_raw = str(t.play_duration) if t.play_duration else "0:00:00"
1214
+ # Compact duration immediately to ensure it fits in the column
1215
+ total = _compact_duration(total_raw)
1216
+ recent_entries.append(f"Recent #{i}:\t\t\t{name} | {cat} | last played {last_played} | total {total}")
1217
+
1218
+ # Decide column widths based on terminal size
1219
+ try:
1220
+ import shutil
1221
+ term_width = shutil.get_terminal_size(fallback=(100, 24)).columns
1222
+ except Exception:
1223
+ term_width = 100
1224
+
1225
+ w_num = 3
1226
+ w_platform = 8
1227
+ w_last = 24
1228
+ w_total = 14 # fits "999d 23:59:59" (14 chars) after compacting "X day(s), HH:MM:SS" -> "Xd HH:MM:SS"
1229
+ fixed = 1 + w_num + 2 + w_platform + 2 + w_last + 2 + w_total
1230
+ w_title = max(24, term_width - fixed)
1231
+
1232
+ # Only print the table if we have entries
1233
+ if recent_entries:
1234
+ print()
1235
+ hdr = f"{'#'.ljust(w_num)} {'Title'.ljust(w_title)} {'Platform'.ljust(w_platform)} {'Last played'.ljust(w_last)} {'Total'.ljust(w_total)}"
1236
+ sep = f"{'-' * w_num} {'-' * w_title} {'-' * w_platform} {'-' * w_last} {'-' * w_total}"
1237
+ print(hdr)
1238
+ print(sep)
1239
+
1240
+ for i, entry in enumerate(recent_entries, 1):
1241
+ try:
1242
+ _, rest = entry.split(":", 1)
1243
+ parts = rest.strip().split("|")
1244
+ name = parts[0].strip()
1245
+ cat = parts[1].strip()
1246
+ last_played = parts[2].replace("last played", "").strip()
1247
+ total = _compact_duration(parts[3].replace("total", "").strip())
1248
+ except Exception:
1249
+ # If parsing ever fails, print raw line as a fallback
1250
+ print(entry)
1251
+ continue
1252
+
1253
+ name_fmt = _shorten_middle(name, w_title)
1254
+ row = (
1255
+ f"{str(i).ljust(w_num)} "
1256
+ f"{name_fmt.ljust(w_title)} "
1257
+ f"{cat.ljust(w_platform)} "
1258
+ f"{last_played.ljust(w_last)} "
1259
+ f"{total.ljust(w_total)}"
1260
+ )
1261
+ print(row)
1262
+ except Exception:
1263
+ pass
1264
+
1265
+ if game_name:
1266
+ launchplatform_str = ""
1267
+ if launchplatform:
1268
+ launchplatform_str = f" ({launchplatform})"
1269
+ print(f"\nUser is currently in-game:\t{game_name}{launchplatform_str}")
1270
+
1271
+
782
1272
  # Main function that monitors gaming activity of the specified PSN user
783
1273
  def psn_monitor_user(psn_user_id, csv_file_name):
784
1274
 
@@ -801,6 +1291,8 @@ def psn_monitor_user(psn_user_id, csv_file_name):
801
1291
  except Exception as e:
802
1292
  print(f"* Error: {e}")
803
1293
 
1294
+ print("Sneaking into PlayStation like a ninja ... (be patient, secrets take time)\n")
1295
+
804
1296
  try:
805
1297
  psnawp = PSNAWP(PSN_NPSSO)
806
1298
  psn_user = psnawp.user(online_id=psn_user_id)
@@ -808,6 +1300,10 @@ def psn_monitor_user(psn_user_id, csv_file_name):
808
1300
  profile = psn_user.profile()
809
1301
  aboutme = profile.get("aboutMe")
810
1302
  isplus = profile.get("isPlus")
1303
+ langs = profile.get("languages") or []
1304
+ is_verified = profile.get("isOfficiallyVerified")
1305
+ fs = psn_user.friendship()
1306
+ share = psn_user.get_shareable_profile_link()
811
1307
  except Exception as e:
812
1308
  print("* Error:", e)
813
1309
  sys.exit(1)
@@ -827,19 +1323,23 @@ def psn_monitor_user(psn_user_id, csv_file_name):
827
1323
  status = str(status).lower()
828
1324
 
829
1325
  psn_platform = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("platform")
830
- psn_platform = str(psn_platform).upper()
1326
+ psn_platform = str(psn_platform).upper() if psn_platform else ""
831
1327
  lastonline = psn_user_presence["basicPresence"]["primaryPlatformInfo"].get("lastOnlineDate")
1328
+ availability = psn_user_presence["basicPresence"].get("availability")
832
1329
 
833
1330
  lastonline_dt = convert_iso_str_to_datetime(lastonline)
834
1331
  if lastonline_dt:
835
1332
  lastonline_ts = int(lastonline_dt.timestamp())
836
1333
  else:
837
1334
  lastonline_ts = 0
1335
+
838
1336
  gametitleinfolist = psn_user_presence["basicPresence"].get("gameTitleInfoList")
839
1337
  game_name = ""
840
1338
  launchplatform = ""
1339
+
841
1340
  if gametitleinfolist:
842
- game_name = gametitleinfolist[0].get("titleName")
1341
+ game_name_raw = gametitleinfolist[0].get("titleName")
1342
+ game_name = normalize_ascii(game_name_raw) if game_name_raw else ""
843
1343
  launchplatform = gametitleinfolist[0].get("launchPlatform")
844
1344
  launchplatform = str(launchplatform).upper()
845
1345
 
@@ -905,12 +1405,57 @@ def psn_monitor_user(psn_user_id, csv_file_name):
905
1405
  print(f"PSN account ID:\t\t\t{accountid}")
906
1406
 
907
1407
  print(f"\nStatus:\t\t\t\t{str(status).upper()}")
1408
+ if availability:
1409
+ available_str = "Yes" if availability == "availableToPlay" else "No"
1410
+ print(f"Available to play:\t\t{available_str}")
1411
+
1412
+ psn_platform_displayed = False
908
1413
  if psn_platform:
909
- print(f"Platform:\t\t\t{psn_platform}")
1414
+ print(f"\nPlatform:\t\t\t{psn_platform}")
1415
+ psn_platform_displayed = True
1416
+
1417
+ if not psn_platform_displayed:
1418
+ print()
910
1419
  print(f"PS+ user:\t\t\t{isplus}")
911
1420
 
1421
+ # an official account belonging to a recognised developer, publisher, community manager or another official role
1422
+ if is_verified is not None:
1423
+ print(f"Verified:\t\t\t{is_verified}")
1424
+
1425
+ newline_needed = False
1426
+
912
1427
  if aboutme:
913
1428
  print(f"\nAbout me:\t\t\t{aboutme}")
1429
+ newline_needed = True
1430
+
1431
+ if langs:
1432
+ prefix = "\n" if not newline_needed else ""
1433
+ print(f"{prefix}Languages:\t\t\t{', '.join(langs)}")
1434
+
1435
+ try:
1436
+ relation = fs.get("friendRelation")
1437
+ print(f"\nRelation:\t\t\t{relation}")
1438
+
1439
+ if relation == "friend":
1440
+ mf = fs.get("mutualFriendsCount")
1441
+ if isinstance(mf, int) and mf >= 0:
1442
+ print(f"Mutual friends:\t\t\t{mf}")
1443
+ elif mf is None:
1444
+ print("Mutual friends:\t\t\tunknown")
1445
+ else:
1446
+ print("Mutual friends:\t\t\thidden")
1447
+ else:
1448
+ # Don't print mutual friends at all
1449
+ pass
1450
+
1451
+ except Exception:
1452
+ pass
1453
+
1454
+ try:
1455
+ print(f"\nProfile URL:\t\t\t{share.get('shareUrl')}")
1456
+ # print(f"Profile QR image:\t\t{share.get('shareImageUrl')}")
1457
+ except Exception:
1458
+ pass
914
1459
 
915
1460
  if status != "offline" and game_name:
916
1461
  launchplatform_str = ""
@@ -947,6 +1492,7 @@ def psn_monitor_user(psn_user_id, csv_file_name):
947
1492
  email_sent = False
948
1493
 
949
1494
  m_subject = m_body = ""
1495
+ error_streak = 0
950
1496
 
951
1497
  def get_sleep_interval():
952
1498
  return PSN_ACTIVE_CHECK_INTERVAL if status and status != "offline" else PSN_CHECK_INTERVAL
@@ -968,7 +1514,8 @@ def psn_monitor_user(psn_user_id, csv_file_name):
968
1514
  game_name = ""
969
1515
  launchplatform = ""
970
1516
  if gametitleinfolist:
971
- game_name = gametitleinfolist[0].get("titleName")
1517
+ game_name_raw = gametitleinfolist[0].get("titleName")
1518
+ game_name = normalize_ascii(game_name_raw) if game_name_raw else ""
972
1519
  launchplatform = gametitleinfolist[0].get("launchPlatform")
973
1520
  launchplatform = str(launchplatform).upper()
974
1521
  if platform.system() != 'Windows':
@@ -1004,15 +1551,45 @@ def psn_monitor_user(psn_user_id, csv_file_name):
1004
1551
  if platform.system() != 'Windows':
1005
1552
  signal.alarm(0)
1006
1553
 
1007
- if 'Remote end closed connection' in str(e):
1554
+ msg = str(e).lower()
1555
+ # Connection-related errors that can often be fixed by recreating the session
1556
+ connection_error = ('remote end closed connection' in msg or 'connection reset by peer' in msg or 'connection aborted' in msg)
1557
+
1558
+ if connection_error:
1008
1559
  try:
1009
1560
  psnawp = PSNAWP(PSN_NPSSO)
1010
1561
  psn_user = psnawp.user(online_id=psn_user_id)
1011
1562
  except Exception:
1012
1563
  pass
1013
- time.sleep(FUNCTION_TIMEOUT)
1564
+ error_streak += 1
1565
+ # For connection errors, retry quickly since they're often transient
1566
+ retry_delay = FUNCTION_TIMEOUT
1567
+ # However, if connection errors persist, it might indicate an expired NPSSO token
1568
+ # Send notification after 5+ consecutive connection errors
1569
+ if ERROR_NOTIFICATION and not email_sent and error_streak >= 5:
1570
+ print(f"* Multiple consecutive connection errors detected - this may indicate an expired NPSSO token, error streak {error_streak}: {e}")
1571
+ m_subject = f"psn_monitor: PSN NPSSO key might be expired! (user: {psn_user_id})"
1572
+ m_body = f"Multiple consecutive connection errors detected - this may indicate an expired NPSSO token.\n\nError: {e}\n\nError streak: {error_streak}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1573
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1574
+ send_email(m_subject, m_body, "", SMTP_SSL)
1575
+ email_sent = True
1576
+ print_cur_ts("Timestamp:\t\t\t")
1577
+ # print(f"* Connection error, recreating session and retrying in {display_time(retry_delay)}: {e}")
1578
+ # print_cur_ts("Timestamp:\t\t\t")
1579
+ time.sleep(retry_delay)
1014
1580
  continue
1015
1581
 
1582
+ error_streak += 1
1583
+
1584
+ # Check for authentication errors (excluding connection errors which we already handled)
1585
+ likely_auth = ('401' in msg) or ('expired' in msg) or ('invalid' in msg)
1586
+ if likely_auth and ERROR_NOTIFICATION and not email_sent and error_streak >= 5:
1587
+ m_subject = f"psn_monitor: PSN NPSSO key error! (user: {psn_user_id})"
1588
+ m_body = f"PSN NPSSO key might not be valid anymore: {e}{get_cur_ts(nl_ch + nl_ch + 'Timestamp: ')}"
1589
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
1590
+ send_email(m_subject, m_body, "", SMTP_SSL)
1591
+ email_sent = True
1592
+
1016
1593
  sleep_interval = get_sleep_interval()
1017
1594
  print(f"* Error, retrying in {display_time(sleep_interval)}: {e}")
1018
1595
  print_cur_ts("Timestamp:\t\t\t")
@@ -1021,6 +1598,7 @@ def psn_monitor_user(psn_user_id, csv_file_name):
1021
1598
 
1022
1599
  else:
1023
1600
  email_sent = False
1601
+ error_streak = 0
1024
1602
 
1025
1603
  finally:
1026
1604
  if platform.system() != 'Windows':
@@ -1274,6 +1852,27 @@ def main():
1274
1852
  help="Send test email to verify SMTP settings"
1275
1853
  )
1276
1854
 
1855
+ # User information
1856
+ info = parser.add_argument_group("User information")
1857
+ info.add_argument(
1858
+ "-i", "--info",
1859
+ dest="info_mode",
1860
+ action="store_true",
1861
+ help="Get detailed user information and display it, then exit"
1862
+ )
1863
+ info.add_argument(
1864
+ "--trophies",
1865
+ dest="include_trophies",
1866
+ action="store_true",
1867
+ help="Show trophy summary and last earned trophies (only works with -i/--info)"
1868
+ )
1869
+ info.add_argument(
1870
+ "--no-recent-games",
1871
+ dest="no_recent_games",
1872
+ action="store_true",
1873
+ help="Don't fetch recently played games list (only works with -i/--info)"
1874
+ )
1875
+
1277
1876
  # Intervals & timers
1278
1877
  times = parser.add_argument_group("Intervals & timers")
1279
1878
  times.add_argument(
@@ -1403,6 +2002,12 @@ def main():
1403
2002
  print("* Error: PSN_NPSSO (-n / --npsso_key) value is empty or incorrect")
1404
2003
  sys.exit(1)
1405
2004
 
2005
+ if args.info_mode:
2006
+ include_trophies = args.include_trophies if hasattr(args, 'include_trophies') and args.include_trophies else False
2007
+ show_recent_games = not (hasattr(args, 'no_recent_games') and args.no_recent_games)
2008
+ get_user_info(args.psn_user_id, include_trophies=include_trophies, show_recent_games=show_recent_games)
2009
+ sys.exit(0)
2010
+
1406
2011
  if args.check_interval:
1407
2012
  PSN_CHECK_INTERVAL = args.check_interval
1408
2013
  LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / PSN_CHECK_INTERVAL
@@ -1,7 +0,0 @@
1
- psn_monitor.py,sha256=a84GPByu6XlwlCm04lnNUWng9IXkJFQpCCZxPa_IX8Y,55082
2
- psn_monitor-1.5.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- psn_monitor-1.5.1.dist-info/METADATA,sha256=l5yH9pB3Hwvwx-Ik_09iP6rzxx6ab2Ipd5WBKRaR7cM,12577
4
- psn_monitor-1.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- psn_monitor-1.5.1.dist-info/entry_points.txt,sha256=2yXV06LBmzhWnvc4bKuEblezaWSHEWKdB2HClGXZlnk,49
6
- psn_monitor-1.5.1.dist-info/top_level.txt,sha256=IG37NL5yiB0wgx_MN-L47SDxKdqRXzjIqBpkU7JiISE,12
7
- psn_monitor-1.5.1.dist-info/RECORD,,