psntui 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 psnTUI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
psntui-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: psntui
3
+ Version: 0.1.0
4
+ Summary: PSN Trophy Tracker TUI — sync and browse your PlayStation trophies from the terminal
5
+ Author: TripShuti
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TripShuti/psnTUI
8
+ Project-URL: Repository, https://github.com/TripShuti/psnTUI
9
+ Requires-Python: >=3.11
10
+ License-File: LICENSE
11
+ Requires-Dist: psnawp>=3.0
12
+ Requires-Dist: textual>=2.0
13
+ Requires-Dist: platformdirs>=4.0
14
+ Dynamic: license-file
psntui-0.1.0/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # psnTUI
2
+
3
+ Terminal UI for browsing and syncing PlayStation trophies.
4
+
5
+ ## Features
6
+
7
+ - **Sync trophies** from PSN — incremental, only fetches updated games
8
+ - **Game library** — progress, trophy counts, last activity
9
+ - **Trophy detail** — per-game list with rarity, earn rate, earned date
10
+ - **Activity heatmap** — 11-week view, click any day to see trophies earned
11
+ - **Month comparison** — current vs previous month
12
+ - **Rarity distribution** — ultra rare / very rare / rare / common breakdown
13
+ - **Play time tracking** — total, today, week, month per game (PS4/PS5 only)
14
+ - **Headless sync** — optional scheduled sync via systemd or Windows Task Scheduler
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install psntui
20
+ ```
21
+
22
+ Or from source:
23
+
24
+ ```bash
25
+ git clone https://github.com/TripShuti/psnTUI
26
+ cd psnTUI
27
+ pip install .
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### First run
33
+
34
+ ```bash
35
+ psntui
36
+ ```
37
+
38
+ Press `a` to authenticate, enter your NPSSO token (64-char code from
39
+ PlayStation), then press `s` to validate and save.
40
+
41
+ ### Getting your NPSSO token
42
+
43
+ 1. Log in to https://ca.account.sony.com
44
+ 2. Open DevTools → Application → Cookies or go to https://ca.account.sony.com/api/v1/ssocookie
45
+ 3. Copy the value of `npsso`
46
+
47
+ ### Controls
48
+
49
+ | Key | Action |
50
+ |-----|--------|
51
+ | `r` | Sync trophies from PSN |
52
+ | `a` | Auth screen |
53
+ | `f` | Search game |
54
+ | `q` | Quit |
55
+ | `Esc` | Back (from game detail) |
56
+ | Click | Select game / trophies / heatmap day |
57
+
58
+ ### Headless sync (Linux — systemd)
59
+
60
+ ```bash
61
+ bash systemd/install.sh
62
+ ```
63
+
64
+ Installs a timer that syncs every 4 hours (15 min after boot).
65
+
66
+ Manual trigger on any platform:
67
+
68
+ ```bash
69
+ psntui --sync
70
+ ```
71
+
72
+ ### Headless sync (Windows — Task Scheduler)
73
+
74
+ Run `psntui --sync` manually or create a scheduled task:
75
+
76
+ ```powershell
77
+ # Run once
78
+ psntui --sync
79
+
80
+ # Create a task that runs every 4 hours
81
+ $action = New-ScheduledTaskAction -Execute "psntui" -Argument "--sync"
82
+ $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Hours 4) -AtStartup
83
+ Register-ScheduledTask -TaskName "psnTUI Sync" -Action $action -Trigger $trigger -RunLevel Highest
84
+ ```
85
+
86
+ ## Data
87
+
88
+ - **Linux:** `~/.config/psntui/`
89
+ - **Windows:** `%APPDATA%\psntui\`
90
+ - **macOS:** `~/Library/Application Support/psntui/`
91
+ - SQLite database with WAL mode
92
+
93
+ ## Requirements
94
+
95
+ - Python ≥ 3.11
96
+ - A terminal with Unicode and TrueColour support (Windows Terminal, Windows Console Host with Virtual Terminal enabled, or any modern terminal on Linux/macOS)
97
+ - PlayStation Network account with games on PS4/PS5
98
+ - NPSSO token from PSN
99
+
100
+ ## License
101
+
102
+ MIT
File without changes
@@ -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
@@ -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()
@@ -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
+