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 +21 -0
- psntui-0.1.0/PKG-INFO +14 -0
- psntui-0.1.0/README.md +102 -0
- psntui-0.1.0/psntui/__init__.py +0 -0
- psntui-0.1.0/psntui/auth.py +44 -0
- psntui-0.1.0/psntui/cli.py +66 -0
- psntui-0.1.0/psntui/db.py +384 -0
- psntui-0.1.0/psntui/sync.py +256 -0
- psntui-0.1.0/psntui/tui/__init__.py +0 -0
- psntui-0.1.0/psntui/tui/app.py +136 -0
- psntui-0.1.0/psntui/tui/screens/__init__.py +0 -0
- psntui-0.1.0/psntui/tui/screens/auth.py +88 -0
- psntui-0.1.0/psntui/tui/screens/game_detail.py +145 -0
- psntui-0.1.0/psntui/tui/screens/main.py +527 -0
- psntui-0.1.0/psntui/tui/theme.py +15 -0
- psntui-0.1.0/psntui.egg-info/PKG-INFO +14 -0
- psntui-0.1.0/psntui.egg-info/SOURCES.txt +22 -0
- psntui-0.1.0/psntui.egg-info/dependency_links.txt +1 -0
- psntui-0.1.0/psntui.egg-info/entry_points.txt +2 -0
- psntui-0.1.0/psntui.egg-info/requires.txt +3 -0
- psntui-0.1.0/psntui.egg-info/top_level.txt +1 -0
- psntui-0.1.0/pyproject.toml +24 -0
- psntui-0.1.0/setup.cfg +4 -0
- psntui-0.1.0/tests/test_db.py +175 -0
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
|
+
|