psntui 0.1.0__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.
psntui/__init__.py ADDED
File without changes
psntui/auth.py ADDED
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+ from platformdirs import user_config_dir
4
+
5
+
6
+ APP_DIR = Path(user_config_dir("psntui", ensure_exists=True))
7
+ CONFIG_PATH = APP_DIR / "config.json"
8
+ DB_PATH = APP_DIR / "trophies.db"
9
+
10
+
11
+ def get_config_path() -> Path:
12
+ return CONFIG_PATH
13
+
14
+
15
+ def get_db_path() -> Path:
16
+ return DB_PATH
17
+
18
+
19
+ def load_config() -> dict:
20
+ if CONFIG_PATH.exists():
21
+ return json.loads(CONFIG_PATH.read_text())
22
+ return {}
23
+
24
+
25
+ def save_config(config: dict) -> None:
26
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
27
+ CONFIG_PATH.write_text(json.dumps(config, indent=2))
28
+ CONFIG_PATH.chmod(0o600)
29
+
30
+
31
+ def is_authenticated() -> bool:
32
+ config = load_config()
33
+ return bool(config.get("npsso"))
34
+
35
+
36
+ def validate_npsso(npsso: str) -> str | None:
37
+ from psnawp_api import PSNAWP
38
+ try:
39
+ psnawp = PSNAWP(npsso_cookie=npsso)
40
+ client = psnawp.me()
41
+ online_id = client.online_id
42
+ return online_id
43
+ except Exception:
44
+ return None
psntui/cli.py ADDED
@@ -0,0 +1,66 @@
1
+ import argparse
2
+ import sys
3
+
4
+
5
+ def headless_sync():
6
+ from . import auth, db, sync
7
+
8
+ config = auth.load_config()
9
+ if not config.get("npsso"):
10
+ print("Error: Not authenticated. Run 'psntui' and use the auth screen.")
11
+ sys.exit(1)
12
+
13
+ db.set_db_path(auth.get_db_path())
14
+ db.init_db()
15
+
16
+ online_id = config.get("online_id", "unknown")
17
+ print(f"Syncing trophies for {online_id}...")
18
+
19
+ def on_progress(current, total, name):
20
+ if total > 0:
21
+ pct = int((current / total) * 100)
22
+ print(f" [{pct:3d}%] ({current+1}/{total}) {name}", end="\r")
23
+ else:
24
+ print(f" ({current+1}) {name}", end="\r")
25
+
26
+ result = sync.sync_trophies(config["npsso"], progress_callback=on_progress)
27
+ print()
28
+
29
+ if result["status"] == "error":
30
+ print(f"Sync failed: {result['error']}")
31
+ sys.exit(1)
32
+ print(f"Done: +{result['trophies_added']} trophies, {result['games_updated']} games updated")
33
+ if result.get("warnings"):
34
+ for w in result["warnings"]:
35
+ print(f" ⚠ {w}")
36
+ db.close_conn()
37
+
38
+
39
+ def main():
40
+ parser = argparse.ArgumentParser(
41
+ prog="psntui",
42
+ description="PSN Trophy Tracker TUI",
43
+ )
44
+ parser.add_argument(
45
+ "--sync", action="store_true",
46
+ help="Run headless sync (for cron/systemd)",
47
+ )
48
+
49
+ args = parser.parse_args()
50
+
51
+ if args.sync:
52
+ headless_sync()
53
+ return
54
+
55
+ from . import auth, db
56
+ from .tui.app import psnTUI
57
+
58
+ db.set_db_path(auth.get_db_path())
59
+ db.init_db()
60
+
61
+ app = psnTUI()
62
+ app.run()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
psntui/db.py ADDED
@@ -0,0 +1,384 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+ from datetime import datetime, date, timedelta
4
+ from typing import Optional
5
+ import time
6
+ import threading
7
+
8
+ DB_PATH: Path | None = None
9
+
10
+ _local = threading.local()
11
+
12
+
13
+ _lock = threading.Lock()
14
+
15
+
16
+ def set_db_path(path: Path) -> None:
17
+ global DB_PATH
18
+ with _lock:
19
+ DB_PATH = path
20
+
21
+
22
+ def get_conn() -> sqlite3.Connection:
23
+ if DB_PATH is None:
24
+ raise RuntimeError("DB_PATH not set")
25
+ conn = getattr(_local, "conn", None)
26
+ if conn is not None:
27
+ try:
28
+ conn.execute("SELECT 1")
29
+ return conn
30
+ except (sqlite3.ProgrammingError, sqlite3.OperationalError):
31
+ try:
32
+ conn.close()
33
+ except sqlite3.Error:
34
+ pass
35
+ conn = sqlite3.connect(str(DB_PATH), timeout=15)
36
+ conn.row_factory = sqlite3.Row
37
+ conn.execute("PRAGMA busy_timeout=10000")
38
+ conn.execute("PRAGMA journal_mode=WAL")
39
+ conn.execute("PRAGMA foreign_keys=ON")
40
+ _local.conn = conn
41
+ return conn
42
+
43
+
44
+ def close_conn() -> None:
45
+ conn = getattr(_local, "conn", None)
46
+ if conn is not None:
47
+ try:
48
+ conn.close()
49
+ except sqlite3.Error:
50
+ pass
51
+ _local.conn = None
52
+
53
+
54
+ SCHEMA = """
55
+ CREATE TABLE IF NOT EXISTS games (
56
+ np_communication_id TEXT PRIMARY KEY,
57
+ np_title_id TEXT,
58
+ title_name TEXT NOT NULL,
59
+ title_icon_url TEXT,
60
+ platform TEXT,
61
+ defined_bronze INTEGER DEFAULT 0,
62
+ defined_silver INTEGER DEFAULT 0,
63
+ defined_gold INTEGER DEFAULT 0,
64
+ defined_platinum INTEGER DEFAULT 0,
65
+ earned_bronze INTEGER DEFAULT 0,
66
+ earned_silver INTEGER DEFAULT 0,
67
+ earned_gold INTEGER DEFAULT 0,
68
+ earned_platinum INTEGER DEFAULT 0,
69
+ progress INTEGER DEFAULT 0,
70
+ last_updated_datetime TEXT,
71
+ created_at TEXT DEFAULT (datetime('now')),
72
+ updated_at TEXT DEFAULT (datetime('now'))
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS trophies (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ np_communication_id TEXT NOT NULL,
78
+ trophy_id INTEGER NOT NULL,
79
+ trophy_name TEXT,
80
+ trophy_detail TEXT,
81
+ trophy_type TEXT,
82
+ trophy_icon_url TEXT,
83
+ trophy_hidden INTEGER DEFAULT 0,
84
+ trophy_group_id TEXT DEFAULT 'default',
85
+ earned INTEGER DEFAULT 0,
86
+ earned_date_time TEXT,
87
+ trophy_rarity TEXT,
88
+ trophy_earn_rate REAL,
89
+ progress INTEGER,
90
+ progress_rate INTEGER,
91
+ UNIQUE(np_communication_id, trophy_id),
92
+ FOREIGN KEY (np_communication_id) REFERENCES games(np_communication_id)
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS game_stats (
96
+ np_communication_id TEXT PRIMARY KEY,
97
+ title_id TEXT,
98
+ total_seconds INTEGER NOT NULL DEFAULT 0,
99
+ play_count INTEGER DEFAULT 0,
100
+ first_played TEXT,
101
+ last_played TEXT,
102
+ FOREIGN KEY (np_communication_id) REFERENCES games(np_communication_id)
103
+ );
104
+
105
+ CREATE TABLE IF NOT EXISTS play_delta_history (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ np_communication_id TEXT NOT NULL,
108
+ date TEXT NOT NULL,
109
+ delta_seconds INTEGER NOT NULL,
110
+ UNIQUE(np_communication_id, date),
111
+ FOREIGN KEY (np_communication_id) REFERENCES games(np_communication_id)
112
+ );
113
+
114
+ CREATE TABLE IF NOT EXISTS sync_log (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ started_at TEXT DEFAULT (datetime('now')),
117
+ finished_at TEXT,
118
+ status TEXT DEFAULT 'running',
119
+ error_message TEXT,
120
+ trophies_added INTEGER DEFAULT 0,
121
+ games_updated INTEGER DEFAULT 0
122
+ );
123
+
124
+ """
125
+
126
+
127
+ def init_db() -> None:
128
+ conn = get_conn()
129
+ try:
130
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
131
+ except sqlite3.OperationalError:
132
+ pass
133
+ conn.executescript(SCHEMA)
134
+
135
+ conn.execute(
136
+ "DELETE FROM play_delta_history WHERE delta_seconds > 86400"
137
+ )
138
+
139
+ try:
140
+ conn.execute(
141
+ "UPDATE sync_log SET status = 'error', error_message = 'Cancelled (stuck)',"
142
+ " finished_at = datetime('now') WHERE status = 'running'"
143
+ )
144
+ conn.commit()
145
+ except sqlite3.OperationalError:
146
+ conn.rollback()
147
+
148
+
149
+ def upsert_game(conn: sqlite3.Connection, g: dict) -> None:
150
+ conn.execute("""
151
+ INSERT INTO games (
152
+ np_communication_id, np_title_id, title_name, title_icon_url, platform,
153
+ defined_bronze, defined_silver, defined_gold, defined_platinum,
154
+ earned_bronze, earned_silver, earned_gold, earned_platinum,
155
+ progress, last_updated_datetime, updated_at
156
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
157
+ ON CONFLICT(np_communication_id) DO UPDATE SET
158
+ np_title_id=excluded.np_title_id, title_name=excluded.title_name,
159
+ title_icon_url=excluded.title_icon_url, platform=excluded.platform,
160
+ defined_bronze=excluded.defined_bronze, defined_silver=excluded.defined_silver,
161
+ defined_gold=excluded.defined_gold, defined_platinum=excluded.defined_platinum,
162
+ earned_bronze=excluded.earned_bronze, earned_silver=excluded.earned_silver,
163
+ earned_gold=excluded.earned_gold, earned_platinum=excluded.earned_platinum,
164
+ progress=excluded.progress, last_updated_datetime=excluded.last_updated_datetime,
165
+ updated_at=excluded.updated_at
166
+ """, (
167
+ g["np_communication_id"], g.get("np_title_id"), g["title_name"],
168
+ g.get("title_icon_url"), g.get("platform"),
169
+ g.get("defined_bronze", 0), g.get("defined_silver", 0),
170
+ g.get("defined_gold", 0), g.get("defined_platinum", 0),
171
+ g.get("earned_bronze", 0), g.get("earned_silver", 0),
172
+ g.get("earned_gold", 0), g.get("earned_platinum", 0),
173
+ g.get("progress", 0), g.get("last_updated_datetime"),
174
+ ))
175
+
176
+
177
+ def upsert_trophy(conn: sqlite3.Connection, t: dict) -> None:
178
+ conn.execute("""
179
+ INSERT INTO trophies (
180
+ np_communication_id, trophy_id, trophy_name, trophy_detail,
181
+ trophy_type, trophy_icon_url, trophy_hidden, trophy_group_id,
182
+ earned, earned_date_time, trophy_rarity, trophy_earn_rate,
183
+ progress, progress_rate
184
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
185
+ ON CONFLICT(np_communication_id, trophy_id) DO UPDATE SET
186
+ trophy_name=excluded.trophy_name, trophy_detail=excluded.trophy_detail,
187
+ trophy_type=excluded.trophy_type, trophy_icon_url=excluded.trophy_icon_url,
188
+ trophy_hidden=excluded.trophy_hidden,
189
+ earned=excluded.earned, earned_date_time=excluded.earned_date_time,
190
+ trophy_rarity=excluded.trophy_rarity, trophy_earn_rate=excluded.trophy_earn_rate,
191
+ progress=excluded.progress, progress_rate=excluded.progress_rate
192
+ """, (
193
+ t["np_communication_id"], t["trophy_id"], t.get("trophy_name"),
194
+ t.get("trophy_detail"), t.get("trophy_type"), t.get("trophy_icon_url"),
195
+ int(t.get("trophy_hidden", False)), t.get("trophy_group_id", "default"),
196
+ int(t.get("earned", False)), t.get("earned_date_time"),
197
+ t.get("trophy_rarity"), t.get("trophy_earn_rate"),
198
+ t.get("progress"), t.get("progress_rate"),
199
+ ))
200
+
201
+
202
+ def write_game_with_trophies(conn: sqlite3.Connection, game: dict, trophies: list[dict]) -> None:
203
+ upsert_game(conn, game)
204
+ for t in trophies:
205
+ upsert_trophy(conn, t)
206
+
207
+
208
+ def start_sync(conn: sqlite3.Connection) -> int:
209
+ cur = conn.execute("INSERT INTO sync_log (started_at) VALUES (datetime('now'))")
210
+ return cur.lastrowid or 0
211
+
212
+
213
+ def finish_sync(conn: sqlite3.Connection, sync_id: int, status: str, trophies_added: int = 0,
214
+ games_updated: int = 0, error_message: str | None = None) -> None:
215
+ conn.execute("""
216
+ UPDATE sync_log SET finished_at = datetime('now'), status = ?, trophies_added = ?,
217
+ games_updated = ?, error_message = ?
218
+ WHERE id = ?
219
+ """, (status, trophies_added, games_updated, error_message, sync_id))
220
+
221
+
222
+ def get_last_sync_time(conn: sqlite3.Connection) -> datetime | None:
223
+ row = conn.execute(
224
+ "SELECT started_at FROM sync_log WHERE status = 'success' ORDER BY started_at DESC LIMIT 1"
225
+ ).fetchone()
226
+ if row:
227
+ return datetime.fromisoformat(row["started_at"])
228
+ return None
229
+
230
+
231
+ def game_exists(conn: sqlite3.Connection, np_comm_id: str) -> bool:
232
+ row = conn.execute(
233
+ "SELECT 1 FROM games WHERE np_communication_id = ?", (np_comm_id,)
234
+ ).fetchone()
235
+ return row is not None
236
+
237
+
238
+ def get_games(conn: sqlite3.Connection) -> list[sqlite3.Row]:
239
+ return conn.execute(
240
+ "SELECT * FROM games ORDER BY title_name COLLATE NOCASE"
241
+ ).fetchall()
242
+
243
+
244
+ def get_game_stats(conn: sqlite3.Connection, np_comm_id: str) -> sqlite3.Row | None:
245
+ return conn.execute(
246
+ "SELECT * FROM game_stats WHERE np_communication_id = ?", (np_comm_id,)
247
+ ).fetchone()
248
+
249
+
250
+ def update_game_stats(conn: sqlite3.Connection, np_comm_id: str,
251
+ title_id: str | None,
252
+ total_seconds: int,
253
+ play_count: int | None,
254
+ first_played: str | None,
255
+ last_played: str | None) -> None:
256
+ old = conn.execute(
257
+ "SELECT total_seconds FROM game_stats WHERE np_communication_id = ?",
258
+ (np_comm_id,)
259
+ ).fetchone()
260
+
261
+ conn.execute("""
262
+ INSERT OR REPLACE INTO game_stats
263
+ (np_communication_id, title_id, total_seconds, play_count, first_played, last_played)
264
+ VALUES (?, ?, ?, ?, ?, ?)
265
+ """, (np_comm_id, title_id, total_seconds, play_count or 0,
266
+ first_played, last_played))
267
+
268
+ if old is not None:
269
+ delta = total_seconds - old["total_seconds"]
270
+ if delta > 0:
271
+ today = date.today().isoformat()
272
+ conn.execute("""
273
+ INSERT INTO play_delta_history (np_communication_id, date, delta_seconds)
274
+ VALUES (?, ?, ?)
275
+ ON CONFLICT(np_communication_id, date)
276
+ DO UPDATE SET delta_seconds = delta_seconds + excluded.delta_seconds
277
+ """, (np_comm_id, today, delta))
278
+
279
+
280
+ def get_play_time(conn: sqlite3.Connection, np_comm_id: str,
281
+ since: str, until: str) -> int:
282
+ row = conn.execute("""
283
+ SELECT COALESCE(SUM(delta_seconds), 0) as total
284
+ FROM play_delta_history
285
+ WHERE np_communication_id = ? AND date >= ? AND date <= ?
286
+ """, (np_comm_id, since, until)).fetchone()
287
+ return row["total"] if row else 0
288
+
289
+
290
+ def get_game(conn: sqlite3.Connection, np_comm_id: str) -> sqlite3.Row | None:
291
+ return conn.execute(
292
+ "SELECT * FROM games WHERE np_communication_id = ?", (np_comm_id,)
293
+ ).fetchone()
294
+
295
+
296
+ def get_trophies(conn: sqlite3.Connection, np_comm_id: str) -> list[sqlite3.Row]:
297
+ return conn.execute(
298
+ "SELECT * FROM trophies WHERE np_communication_id = ? ORDER BY trophy_id",
299
+ (np_comm_id,)
300
+ ).fetchall()
301
+
302
+
303
+ def get_recent_earned(conn: sqlite3.Connection, limit: int = 10) -> list[sqlite3.Row]:
304
+ return conn.execute("""
305
+ SELECT t.*, g.title_name, g.title_icon_url
306
+ FROM trophies t
307
+ JOIN games g ON g.np_communication_id = t.np_communication_id
308
+ WHERE t.earned = 1 AND t.earned_date_time IS NOT NULL
309
+ ORDER BY t.earned_date_time DESC
310
+ LIMIT ?
311
+ """, (limit,)).fetchall()
312
+
313
+
314
+ def get_trophy_summary(conn: sqlite3.Connection) -> dict:
315
+ row = conn.execute("""
316
+ SELECT
317
+ COUNT(DISTINCT np_communication_id) as total_games,
318
+ SUM(CASE WHEN earned = 1 THEN 1 ELSE 0 END) as total_earned,
319
+ COUNT(*) as total_trophies,
320
+ SUM(CASE WHEN trophy_type = 'platinum' AND earned = 1 THEN 1 ELSE 0 END) as platinum,
321
+ SUM(CASE WHEN trophy_type = 'gold' AND earned = 1 THEN 1 ELSE 0 END) as gold,
322
+ SUM(CASE WHEN trophy_type = 'silver' AND earned = 1 THEN 1 ELSE 0 END) as silver,
323
+ SUM(CASE WHEN trophy_type = 'bronze' AND earned = 1 THEN 1 ELSE 0 END) as bronze
324
+ FROM trophies
325
+ """).fetchone()
326
+ return dict(row) if row else {}
327
+
328
+
329
+ def get_earned_by_date_range(conn: sqlite3.Connection, since: str, until: str) -> list[sqlite3.Row]:
330
+ return conn.execute("""
331
+ SELECT DATE(earned_date_time) as day, COUNT(*) as count
332
+ FROM trophies
333
+ WHERE earned = 1 AND earned_date_time >= ? AND earned_date_time < ?
334
+ GROUP BY DATE(earned_date_time)
335
+ ORDER BY day
336
+ """, (since, until)).fetchall()
337
+
338
+
339
+ def get_trophies_by_date(conn: sqlite3.Connection, date_str: str) -> list[sqlite3.Row]:
340
+ return conn.execute("""
341
+ SELECT t.*, g.title_name
342
+ FROM trophies t
343
+ JOIN games g ON g.np_communication_id = t.np_communication_id
344
+ WHERE t.earned = 1 AND DATE(t.earned_date_time) = ?
345
+ ORDER BY t.earned_date_time
346
+ """, (date_str,)).fetchall()
347
+
348
+
349
+ def get_consecutive_days(conn: sqlite3.Connection) -> int:
350
+ rows = conn.execute("""
351
+ SELECT DISTINCT DATE(earned_date_time) as day
352
+ FROM trophies
353
+ WHERE earned = 1 AND earned_date_time IS NOT NULL
354
+ ORDER BY day DESC
355
+ """).fetchall()
356
+
357
+ if not rows:
358
+ return 0
359
+
360
+ streak = 0
361
+ expected = date.today()
362
+
363
+ for row in rows:
364
+ d = date.fromisoformat(row["day"])
365
+ if d == expected:
366
+ streak += 1
367
+ expected -= timedelta(days=1)
368
+ elif d < expected:
369
+ break
370
+ # skip future dates (shouldn't happen, but defensive)
371
+
372
+ return streak
373
+
374
+
375
+ def get_earned_month(conn: sqlite3.Connection, year: int, month: int) -> int:
376
+ row = conn.execute("""
377
+ SELECT COUNT(*) as count FROM trophies
378
+ WHERE earned = 1
379
+ AND strftime('%Y', earned_date_time) = ?
380
+ AND strftime('%m', earned_date_time) = ?
381
+ """, (str(year), f"{month:02d}")).fetchone()
382
+ return row["count"] if row else 0
383
+
384
+