recently-splatted-with 1.0.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.
- recently_splatted_with/__init__.py +1 -0
- recently_splatted_with/__main__.py +7 -0
- recently_splatted_with/data_models.py +61 -0
- recently_splatted_with/main.py +56 -0
- recently_splatted_with/mii_fetcher.py +263 -0
- recently_splatted_with/parser.py +285 -0
- recently_splatted_with/resources/icon.ico +0 -0
- recently_splatted_with/resources/icon.png +0 -0
- recently_splatted_with/themes.py +300 -0
- recently_splatted_with/ui/__init__.py +0 -0
- recently_splatted_with/ui/main_window.py +336 -0
- recently_splatted_with/ui/player_card.py +137 -0
- recently_splatted_with-1.0.0.dist-info/METADATA +93 -0
- recently_splatted_with-1.0.0.dist-info/RECORD +18 -0
- recently_splatted_with-1.0.0.dist-info/WHEEL +5 -0
- recently_splatted_with-1.0.0.dist-info/entry_points.txt +3 -0
- recently_splatted_with-1.0.0.dist-info/licenses/LICENSE +19 -0
- recently_splatted_with-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# RecentlySplattedWith (c) 2026 Shadow Doggo.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PlayerEntry:
|
|
11
|
+
pid: int
|
|
12
|
+
pnid: str
|
|
13
|
+
name: str
|
|
14
|
+
mii_name: str | None = None
|
|
15
|
+
match_time: datetime | None = None
|
|
16
|
+
session_id: str | None = None
|
|
17
|
+
mii_icon_url: str | None = None
|
|
18
|
+
source_file: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PlayerRecord:
|
|
23
|
+
pid: int
|
|
24
|
+
pnid: str
|
|
25
|
+
name: str
|
|
26
|
+
mii_name: str | None = None
|
|
27
|
+
aliases: list[str] = field(default_factory=list)
|
|
28
|
+
last_encountered: datetime | None = None
|
|
29
|
+
games_played: int = 0
|
|
30
|
+
mii_icon_url: str | None = None
|
|
31
|
+
encounters: list[PlayerEntry] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def pid_str(self) -> str:
|
|
35
|
+
return str(self.pid)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def formatted_last_encountered(self) -> str:
|
|
39
|
+
if self.last_encountered:
|
|
40
|
+
return self.last_encountered.strftime("%Y-%m-%d %H:%M:%S")
|
|
41
|
+
return "Unknown"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def aliases_display(self) -> str:
|
|
45
|
+
return ", ".join(self.aliases)
|
|
46
|
+
|
|
47
|
+
def add_alias(self, alias: str | None) -> bool:
|
|
48
|
+
if not alias or not isinstance(alias, str):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
clean = alias.strip()
|
|
52
|
+
if not clean or clean == self.name:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
if any(a == clean for a in self.aliases):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
self.aliases.append(clean)
|
|
59
|
+
self.aliases.sort()
|
|
60
|
+
|
|
61
|
+
return True
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# RecentlySplattedWith (c) 2026 Shadow Doggo.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import userpaths
|
|
11
|
+
from PySide6.QtGui import QIcon
|
|
12
|
+
from PySide6.QtWidgets import QApplication
|
|
13
|
+
|
|
14
|
+
from .ui.main_window import MainWindow
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
app = QApplication()
|
|
19
|
+
app.setOrganizationName("RecentlySplattedWith")
|
|
20
|
+
app.setApplicationName("RecentlySplattedWith")
|
|
21
|
+
|
|
22
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--create-shortcut":
|
|
23
|
+
# print("Not supported for the executable")
|
|
24
|
+
create_desktop_shortcut()
|
|
25
|
+
|
|
26
|
+
icon_path = resources.files(__package__).joinpath("resources", "icon.png")
|
|
27
|
+
# icon_path = Path(sys._MEIPASS) / "resources/icon.png"
|
|
28
|
+
if icon_path.is_file():
|
|
29
|
+
app.setWindowIcon(QIcon(str(icon_path)))
|
|
30
|
+
|
|
31
|
+
window = MainWindow(initial_path=Path(userpaths.get_my_documents()) / "Splatlogger/logs")
|
|
32
|
+
|
|
33
|
+
window.show()
|
|
34
|
+
sys.exit(app.exec())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_desktop_shortcut() -> None:
|
|
38
|
+
from pyshortcuts import make_shortcut
|
|
39
|
+
|
|
40
|
+
executable = None
|
|
41
|
+
if platform.system() == "Windows":
|
|
42
|
+
pythonw_path = Path(sys.executable).with_name("pythonw.exe")
|
|
43
|
+
if pythonw_path.exists():
|
|
44
|
+
executable = str(pythonw_path)
|
|
45
|
+
else:
|
|
46
|
+
executable = sys.executable.replace("python.exe", "pythonw.exe")
|
|
47
|
+
|
|
48
|
+
make_shortcut(
|
|
49
|
+
str(resources.files(__package__).joinpath("__main__.py")),
|
|
50
|
+
name="RecentlySplattedWith",
|
|
51
|
+
description="View a list of players you've recently encountered in Splatoon matches",
|
|
52
|
+
icon=str(resources.files(__package__)
|
|
53
|
+
.joinpath("resources","icon.ico" if platform.system() == "Windows" else "icon.png")),
|
|
54
|
+
executable=executable,
|
|
55
|
+
terminal=False,
|
|
56
|
+
)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# RecentlySplattedWith (c) 2026 Shadow Doggo.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from xml.etree import ElementTree
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
import userpaths
|
|
13
|
+
from PySide6.QtCore import QObject, QRunnable, QStandardPaths, Qt, QThreadPool, Signal
|
|
14
|
+
from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPainterPath, QPixmap
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_mii_url(pid: int, timeout: int = 5, session: requests.Session | None = None) -> str | None:
|
|
18
|
+
if pid <= 0:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
api_url = f"https://account.pretendo.cc/v1/api/miis?pids={pid}"
|
|
22
|
+
headers = {
|
|
23
|
+
"User-Agent": "Mozilla/5.0",
|
|
24
|
+
"X-Nintendo-Client-ID": "a2efa818a34fa16b8afbc8a74eba3eda",
|
|
25
|
+
"X-Nintendo-Client-Secret": "c91cdb5658bd4954ade78533a339cf9a",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
url: str | None = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
client = session if session is not None else requests
|
|
32
|
+
response = client.get(api_url, headers=headers, timeout=timeout)
|
|
33
|
+
if response.status_code == 200:
|
|
34
|
+
url = _get_mii_icon_url(response.text)
|
|
35
|
+
except requests.RequestException:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Fallback to CDN url
|
|
39
|
+
if not url:
|
|
40
|
+
url = f"https://r2-cdn.pretendo.cc/mii/{pid}/normal_face.png"
|
|
41
|
+
|
|
42
|
+
return url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_placeholder_pixmap(size: int) -> QPixmap:
|
|
46
|
+
pixmap = QPixmap(size, size)
|
|
47
|
+
pixmap.fill(QColor(0, 0, 0, 0))
|
|
48
|
+
|
|
49
|
+
painter = QPainter(pixmap)
|
|
50
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
51
|
+
|
|
52
|
+
path = QPainterPath()
|
|
53
|
+
path.addEllipse(2, 2, size - 4, size - 4)
|
|
54
|
+
|
|
55
|
+
painter.fillPath(path, QColor(80, 85, 100))
|
|
56
|
+
painter.setPen(QColor(120, 125, 145))
|
|
57
|
+
painter.drawPath(path)
|
|
58
|
+
|
|
59
|
+
painter.setPen(QColor(230, 235, 245))
|
|
60
|
+
font = QFont("Sans-Serif", int(size * 0.4), QFont.Weight.Bold)
|
|
61
|
+
painter.setFont(font)
|
|
62
|
+
painter.drawText(pixmap.rect(), int(Qt.AlignmentFlag.AlignCenter), "?")
|
|
63
|
+
painter.end()
|
|
64
|
+
|
|
65
|
+
return pixmap
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_mii_icon_url(response_txt: str) -> str | None:
|
|
69
|
+
try:
|
|
70
|
+
root = ElementTree.fromstring(response_txt)
|
|
71
|
+
if len(root) == 0:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
mii_img_url = root.find(".//images/image/url")
|
|
75
|
+
if mii_img_url is not None and mii_img_url.text:
|
|
76
|
+
return mii_img_url.text.strip()
|
|
77
|
+
|
|
78
|
+
cached_url_elem = root.find(".//images/image/cached_url")
|
|
79
|
+
if cached_url_elem is not None and cached_url_elem.text:
|
|
80
|
+
return cached_url_elem.text.strip()
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
except ElementTree.ParseError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_default_cache_dir() -> Path:
|
|
88
|
+
try:
|
|
89
|
+
generic_cache = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation)
|
|
90
|
+
if generic_cache:
|
|
91
|
+
cache_dir = Path(generic_cache) / "RecentlySplattedWith"
|
|
92
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
return cache_dir
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
if platform.system() == "Windows":
|
|
98
|
+
cache_dir = Path(userpaths.get_local_appdata()) / "RecentlySplattedWith"
|
|
99
|
+
elif platform.system() == "Darwin":
|
|
100
|
+
cache_dir = Path.home() / "Library" / "Caches" / "RecentlySplattedWith"
|
|
101
|
+
else:
|
|
102
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
|
103
|
+
if xdg_cache:
|
|
104
|
+
cache_dir = Path(xdg_cache) / "RecentlySplattedWith"
|
|
105
|
+
else:
|
|
106
|
+
cache_dir = Path.home() / ".cache" / "RecentlySplattedWith"
|
|
107
|
+
|
|
108
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
return cache_dir
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MiiFetchWorkerSignals(QObject):
|
|
113
|
+
finished = Signal(int, QImage)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MiiFetchWorker(QRunnable):
|
|
117
|
+
"""Background worker to fetch and cache Mii icons."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, pid: int, direct_url: str | None, cache_dir: Path, session: requests.Session | None = None,
|
|
120
|
+
delay: float = 0.2):
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.signals = MiiFetchWorkerSignals()
|
|
123
|
+
self._pid = pid
|
|
124
|
+
self._direct_url = direct_url
|
|
125
|
+
self._cache_dir = cache_dir
|
|
126
|
+
self._session = session
|
|
127
|
+
self._delay = delay
|
|
128
|
+
|
|
129
|
+
def run(self) -> None:
|
|
130
|
+
if self._pid <= 0:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
image_path = self._cache_dir / f"{self._pid}.png"
|
|
134
|
+
qimg = QImage()
|
|
135
|
+
|
|
136
|
+
# Check local file cache
|
|
137
|
+
if image_path.is_file():
|
|
138
|
+
if qimg.load(str(image_path)):
|
|
139
|
+
self.signals.finished.emit(self._pid, qimg)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if self._delay > 0:
|
|
143
|
+
time.sleep(self._delay)
|
|
144
|
+
|
|
145
|
+
url = self._direct_url
|
|
146
|
+
if not url:
|
|
147
|
+
url = fetch_mii_url(self._pid, session=self._session)
|
|
148
|
+
|
|
149
|
+
if url:
|
|
150
|
+
try:
|
|
151
|
+
client = self._session if self._session is not None else requests
|
|
152
|
+
res = client.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=5)
|
|
153
|
+
if res.status_code == 200 and res.content:
|
|
154
|
+
# Save to cache
|
|
155
|
+
image_path.write_bytes(res.content)
|
|
156
|
+
if qimg.loadFromData(res.content):
|
|
157
|
+
self.signals.finished.emit(self._pid, qimg)
|
|
158
|
+
return
|
|
159
|
+
except (requests.RequestException, OSError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class MiiIconManager(QObject):
|
|
164
|
+
"""Handles caching and requesting Mii icons."""
|
|
165
|
+
icon_ready = Signal(int, QPixmap)
|
|
166
|
+
|
|
167
|
+
def __init__(self, cache_dir: Path | None = None, parent: QObject | None = None, delay: float = 0.2):
|
|
168
|
+
super().__init__(parent)
|
|
169
|
+
self._cache_dir = cache_dir or _get_default_cache_dir()
|
|
170
|
+
self._delay = delay
|
|
171
|
+
self._session = requests.Session()
|
|
172
|
+
self._memory_cache: dict[int, QPixmap] = {}
|
|
173
|
+
self._pending_pids: set[int] = set()
|
|
174
|
+
self._thread_pool = QThreadPool()
|
|
175
|
+
self._thread_pool.setMaxThreadCount(4)
|
|
176
|
+
self._clean_cache_by_size()
|
|
177
|
+
|
|
178
|
+
def wait_for_done(self, msecs: int = 1000) -> bool:
|
|
179
|
+
return self._thread_pool.waitForDone(msecs)
|
|
180
|
+
|
|
181
|
+
def get_cached_pixmap(self, pid: int) -> QPixmap | None:
|
|
182
|
+
if pid in self._memory_cache:
|
|
183
|
+
return self._memory_cache[pid]
|
|
184
|
+
|
|
185
|
+
image_path = self._cache_dir / f"{pid}.png"
|
|
186
|
+
if image_path.is_file():
|
|
187
|
+
pixmap = QPixmap()
|
|
188
|
+
if pixmap.load(str(image_path)):
|
|
189
|
+
self._memory_cache[pid] = pixmap
|
|
190
|
+
return pixmap
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def request_icon(self, pid: int, direct_url: str | None = None) -> QPixmap:
|
|
195
|
+
"""Get icon if cached, or queue background fetch and return placeholder."""
|
|
196
|
+
cached = self.get_cached_pixmap(pid)
|
|
197
|
+
|
|
198
|
+
if pid not in self._pending_pids and pid > 0:
|
|
199
|
+
self._pending_pids.add(pid)
|
|
200
|
+
worker = MiiFetchWorker(
|
|
201
|
+
pid,
|
|
202
|
+
direct_url,
|
|
203
|
+
self._cache_dir,
|
|
204
|
+
session=self._session,
|
|
205
|
+
delay=self._delay,
|
|
206
|
+
)
|
|
207
|
+
worker.signals.finished.connect(self._on_image_fetched)
|
|
208
|
+
self._thread_pool.start(worker)
|
|
209
|
+
|
|
210
|
+
if cached:
|
|
211
|
+
return cached
|
|
212
|
+
|
|
213
|
+
return create_placeholder_pixmap(56)
|
|
214
|
+
|
|
215
|
+
def _on_image_fetched(self, pid: int, qimg: QImage) -> None:
|
|
216
|
+
if pid in self._pending_pids:
|
|
217
|
+
self._pending_pids.remove(pid)
|
|
218
|
+
if not qimg.isNull():
|
|
219
|
+
pixmap = QPixmap.fromImage(qimg)
|
|
220
|
+
self._memory_cache[pid] = pixmap
|
|
221
|
+
self.icon_ready.emit(pid, pixmap)
|
|
222
|
+
|
|
223
|
+
def _clean_cache_by_size(self) -> None:
|
|
224
|
+
if not self._cache_dir.exists():
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
max_size_bytes: int = 50 * 1024 * 1024
|
|
228
|
+
target_size_bytes: int = 30 * 1024 * 1024
|
|
229
|
+
max_files: int = 3000
|
|
230
|
+
target_files: int = 2000
|
|
231
|
+
|
|
232
|
+
cached_files: list[tuple[float, int, Path]] = []
|
|
233
|
+
total_size = 0
|
|
234
|
+
|
|
235
|
+
for icon_path in self._cache_dir.glob("*.png"):
|
|
236
|
+
try:
|
|
237
|
+
stat = icon_path.stat()
|
|
238
|
+
cached_files.append((stat.st_mtime, stat.st_size, icon_path))
|
|
239
|
+
total_size += stat.st_size
|
|
240
|
+
except OSError:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if total_size <= max_size_bytes and len(cached_files) <= max_files:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Sort files ascending by modification time (oldest first).
|
|
247
|
+
cached_files.sort(key=lambda item: item[0])
|
|
248
|
+
|
|
249
|
+
current_size = total_size
|
|
250
|
+
current_count = len(cached_files)
|
|
251
|
+
iter_count = 0
|
|
252
|
+
|
|
253
|
+
for _, size, icon_path in cached_files:
|
|
254
|
+
if current_size <= target_size_bytes and current_count <= target_files and iter_count > max_files:
|
|
255
|
+
break
|
|
256
|
+
try:
|
|
257
|
+
icon_path.unlink()
|
|
258
|
+
current_size -= size
|
|
259
|
+
current_count -= 1
|
|
260
|
+
iter_count += 1
|
|
261
|
+
except OSError:
|
|
262
|
+
iter_count += 1
|
|
263
|
+
continue
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# RecentlySplattedWith (c) 2026 Shadow Doggo.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
from .data_models import PlayerEntry, PlayerRecord
|
|
11
|
+
|
|
12
|
+
RE_TIMEZONE = re.compile(r"\(UTC\s*[+-]?\d+\)")
|
|
13
|
+
RE_PID_PARENS = re.compile(r"\((\d+)\)")
|
|
14
|
+
RE_MII_PREFIX = re.compile(r"^(.*?)\s*\(Mii name:\s*(.*?)\)$", re.IGNORECASE)
|
|
15
|
+
RE_MII_PARENS = re.compile(r"^(.*?)\s*\((.*?)\)$")
|
|
16
|
+
|
|
17
|
+
RE_LOG_HEADER = re.compile(r"Splatlogger log from\s+([^\n\r]+)")
|
|
18
|
+
RE_DATE_IN_FILENAME = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
|
19
|
+
|
|
20
|
+
RE_BRACKET_MATCHES = re.compile(r"\[Match\s+\d+")
|
|
21
|
+
RE_PLAIN_MATCHES = re.compile(r"(?:^|\n)Match\s+\d+")
|
|
22
|
+
RE_PLAYER_SECTIONS = re.compile(r"\[Player\s+\d+")
|
|
23
|
+
RE_TABULAR_HEADER = re.compile(r"Player\s*\|\s*PID\s*\|\s*PNID\s*\|\s*Name", re.IGNORECASE)
|
|
24
|
+
RE_TABULAR_SFID_HEADER = re.compile(r"Player\s*\|\s*PID\s*\|\s*SFID\s*\|\s*Name", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
RE_SPLIT_MATCH_BLOCKS = re.compile(r"(?=\[Match\s+\d+)")
|
|
27
|
+
RE_SPLIT_PLAYER_BLOCKS = re.compile(r"(?=\[Player\s+\d+)")
|
|
28
|
+
RE_SPLIT_PLAIN_MATCHES = re.compile(r"(?=(?:^|\n)Match\s+\d+)")
|
|
29
|
+
|
|
30
|
+
RE_TIME = re.compile(r"Time:\s*([^\n\r]+)")
|
|
31
|
+
RE_FETCHED = re.compile(r"Fetched at:\s*([^\n\r]+)")
|
|
32
|
+
RE_SESSION_ID = re.compile(r"Session ID:\s*([^\n\r]+)")
|
|
33
|
+
|
|
34
|
+
RE_NAME_FIELD = re.compile(r"^\s*Name:\s*([^\n\r]+)", re.MULTILINE)
|
|
35
|
+
RE_PID_FIELD = re.compile(r"^\s*PID:\s*([^\n\r]+)", re.MULTILINE)
|
|
36
|
+
RE_PNID_FIELD = re.compile(r"^\s*PNID:\s*([^\n\r]+)", re.MULTILINE)
|
|
37
|
+
RE_MII_NAME_FIELD = re.compile(r"^\s*Mii name:\s*([^\n\r]+)", re.MULTILINE)
|
|
38
|
+
|
|
39
|
+
RE_TABULAR_ROW = re.compile(r"^\s*Player\s+\d+\s*\|\s*([^|]+)\|\s*([^|]+)\|\s*(.+)$")
|
|
40
|
+
|
|
41
|
+
TIMESTAMP_FORMATS = (
|
|
42
|
+
"%Y-%m-%d %H:%M:%S",
|
|
43
|
+
"%Y-%m-%d",
|
|
44
|
+
"%H:%M:%S"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_log_file(file_path: str | Path) -> list[PlayerEntry]:
|
|
49
|
+
path = Path(file_path)
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
except OSError:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
return _parse_log_content(content, file_path=str(path))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def aggregate_players(encounters: Iterable[PlayerEntry]) -> list[PlayerRecord]:
|
|
62
|
+
grouped: dict[tuple[int, str], list[PlayerEntry]] = {}
|
|
63
|
+
|
|
64
|
+
for enc in encounters:
|
|
65
|
+
# Group primarily by PID, fallback to PNID
|
|
66
|
+
key = (enc.pid, enc.pnid if enc.pid == 0 else "")
|
|
67
|
+
if key not in grouped:
|
|
68
|
+
grouped[key] = []
|
|
69
|
+
grouped[key].append(enc)
|
|
70
|
+
|
|
71
|
+
records: list[PlayerRecord] = []
|
|
72
|
+
|
|
73
|
+
for (pid, fallback_pnid), p_encounters in grouped.items():
|
|
74
|
+
if not p_encounters:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
sorted_encounters = sorted(p_encounters, key=lambda e: e.match_time or datetime.min)
|
|
78
|
+
|
|
79
|
+
most_recent = sorted_encounters[-1]
|
|
80
|
+
last_date = most_recent.match_time
|
|
81
|
+
pnid = most_recent.pnid or fallback_pnid
|
|
82
|
+
primary_name = most_recent.name
|
|
83
|
+
latest_mii_name = next((e.mii_name for e in reversed(sorted_encounters) if e.mii_name), None)
|
|
84
|
+
|
|
85
|
+
record = PlayerRecord(
|
|
86
|
+
pid=pid,
|
|
87
|
+
pnid=pnid,
|
|
88
|
+
name=primary_name,
|
|
89
|
+
mii_name=latest_mii_name,
|
|
90
|
+
aliases=[],
|
|
91
|
+
last_encountered=last_date,
|
|
92
|
+
games_played=len(sorted_encounters),
|
|
93
|
+
mii_icon_url=None,
|
|
94
|
+
encounters=sorted_encounters,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
for e in sorted_encounters:
|
|
98
|
+
if e.name:
|
|
99
|
+
record.add_alias(e.name)
|
|
100
|
+
if e.mii_name:
|
|
101
|
+
record.add_alias(e.mii_name)
|
|
102
|
+
|
|
103
|
+
records.append(record)
|
|
104
|
+
|
|
105
|
+
return records
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_and_aggregate_logs(file_paths: Iterable[str | Path]) -> list[PlayerRecord]:
|
|
109
|
+
all_encounters: list[PlayerEntry] = []
|
|
110
|
+
|
|
111
|
+
for path_item in file_paths:
|
|
112
|
+
path = Path(path_item)
|
|
113
|
+
if path.is_file():
|
|
114
|
+
all_encounters.extend(parse_log_file(path))
|
|
115
|
+
elif path.is_dir():
|
|
116
|
+
for f in sorted(path.rglob("*")):
|
|
117
|
+
if f.is_file() and f.suffix == ".txt":
|
|
118
|
+
all_encounters.extend(parse_log_file(f))
|
|
119
|
+
|
|
120
|
+
return aggregate_players(all_encounters)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_timestamp(time_str: str, base_date: datetime | None = None) -> datetime | None:
|
|
124
|
+
time_str = time_str.strip()
|
|
125
|
+
time_str_clean = RE_TIMEZONE.sub("", time_str).strip()
|
|
126
|
+
|
|
127
|
+
for fmt in TIMESTAMP_FORMATS:
|
|
128
|
+
try:
|
|
129
|
+
date = datetime.strptime(time_str_clean, fmt)
|
|
130
|
+
if fmt in ("%H:%M:%S", "%H:%M") and base_date:
|
|
131
|
+
date = base_date.replace(hour=date.hour, minute=date.minute, second=date.second)
|
|
132
|
+
return date
|
|
133
|
+
except ValueError:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_pid(pid_str: str) -> int:
|
|
140
|
+
pid_str = pid_str.strip()
|
|
141
|
+
match = RE_PID_PARENS.search(pid_str)
|
|
142
|
+
if match:
|
|
143
|
+
return int(match.group(1))
|
|
144
|
+
|
|
145
|
+
if pid_str.isdigit():
|
|
146
|
+
return int(pid_str)
|
|
147
|
+
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _clean_name_and_mii(raw_name: str) -> tuple[str, str | None]:
|
|
152
|
+
raw_name = raw_name.strip()
|
|
153
|
+
match = RE_MII_PREFIX.match(raw_name) or RE_MII_PARENS.match(raw_name)
|
|
154
|
+
if match:
|
|
155
|
+
return match.group(1).strip(), match.group(2).strip()
|
|
156
|
+
|
|
157
|
+
return raw_name, None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extract_log_date(content: str) -> datetime | None:
|
|
161
|
+
log_header_match = RE_LOG_HEADER.search(content)
|
|
162
|
+
if log_header_match:
|
|
163
|
+
if date := _parse_timestamp(log_header_match.group(1)):
|
|
164
|
+
return date
|
|
165
|
+
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extract_match_time(text: str, base_date: datetime | None = None) -> datetime | None:
|
|
170
|
+
for regex in (RE_TIME, RE_FETCHED):
|
|
171
|
+
match = regex.search(text)
|
|
172
|
+
if match:
|
|
173
|
+
if date := _parse_timestamp(match.group(1), base_date):
|
|
174
|
+
return date
|
|
175
|
+
|
|
176
|
+
return base_date
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _extract_session_id(text: str) -> str | None:
|
|
180
|
+
sess_match = RE_SESSION_ID.search(text)
|
|
181
|
+
return sess_match.group(1).strip() if sess_match else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_player_block(p_block: str, match_time: datetime | None, session_id: str | None, file_path: str | None,
|
|
185
|
+
) -> PlayerEntry | None:
|
|
186
|
+
name_m = RE_NAME_FIELD.search(p_block)
|
|
187
|
+
pid_m = RE_PID_FIELD.search(p_block)
|
|
188
|
+
pnid_m = RE_PNID_FIELD.search(p_block)
|
|
189
|
+
mii_m = RE_MII_NAME_FIELD.search(p_block)
|
|
190
|
+
|
|
191
|
+
if pid_m and name_m:
|
|
192
|
+
pid = _parse_pid(pid_m.group(1))
|
|
193
|
+
pnid = pnid_m.group(1).strip() if pnid_m else ""
|
|
194
|
+
raw_name = name_m.group(1).strip()
|
|
195
|
+
name, inline_mii = _clean_name_and_mii(raw_name)
|
|
196
|
+
mii_name = mii_m.group(1).strip() if mii_m else inline_mii
|
|
197
|
+
|
|
198
|
+
return PlayerEntry(
|
|
199
|
+
pid=pid,
|
|
200
|
+
pnid=pnid,
|
|
201
|
+
name=name,
|
|
202
|
+
mii_name=mii_name,
|
|
203
|
+
match_time=match_time,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
mii_icon_url=None,
|
|
206
|
+
source_file=file_path,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _parse_player_sections(text: str, match_time: datetime | None, session_id: str | None, file_path: str | None,
|
|
213
|
+
) -> list[PlayerEntry]:
|
|
214
|
+
entries: list[PlayerEntry] = []
|
|
215
|
+
|
|
216
|
+
player_blocks = RE_SPLIT_PLAYER_BLOCKS.split(text)[1:]
|
|
217
|
+
for p_block in player_blocks:
|
|
218
|
+
entry = _parse_player_block(p_block, match_time, session_id, file_path)
|
|
219
|
+
if entry:
|
|
220
|
+
entries.append(entry)
|
|
221
|
+
|
|
222
|
+
return entries
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_tabular_block(block: str, match_time: datetime | None, session_id: str | None, file_path: str | None,
|
|
226
|
+
spfn: bool = False) -> list[PlayerEntry]:
|
|
227
|
+
entries: list[PlayerEntry] = []
|
|
228
|
+
is_sfid = bool(RE_TABULAR_SFID_HEADER.search(block)) or (spfn and not RE_TABULAR_HEADER.search(block))
|
|
229
|
+
|
|
230
|
+
for line in block.splitlines():
|
|
231
|
+
row_match = RE_TABULAR_ROW.match(line)
|
|
232
|
+
if row_match:
|
|
233
|
+
pid = _parse_pid(row_match.group(1))
|
|
234
|
+
# Ignore SFID for now
|
|
235
|
+
pnid = "" if is_sfid else row_match.group(2).strip()
|
|
236
|
+
name, mii_name = _clean_name_and_mii(row_match.group(3))
|
|
237
|
+
entries.append(
|
|
238
|
+
PlayerEntry(
|
|
239
|
+
pid=pid,
|
|
240
|
+
pnid=pnid,
|
|
241
|
+
name=name,
|
|
242
|
+
mii_name=mii_name,
|
|
243
|
+
match_time=match_time,
|
|
244
|
+
session_id=session_id,
|
|
245
|
+
mii_icon_url=None,
|
|
246
|
+
source_file=file_path,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return entries
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_log_content(content: str, file_path: str | None = None) -> list[PlayerEntry]:
|
|
254
|
+
encounters: list[PlayerEntry] = []
|
|
255
|
+
log_date = _extract_log_date(content)
|
|
256
|
+
|
|
257
|
+
has_bracket_matches = bool(RE_BRACKET_MATCHES.search(content))
|
|
258
|
+
has_plain_matches = bool(RE_PLAIN_MATCHES.search(content))
|
|
259
|
+
has_player_sections = bool(RE_PLAYER_SECTIONS.search(content))
|
|
260
|
+
has_tabular_header = bool(RE_TABULAR_HEADER.search(content) or RE_TABULAR_SFID_HEADER.search(content))
|
|
261
|
+
is_spfn_log = bool(RE_TABULAR_SFID_HEADER.search(content))
|
|
262
|
+
|
|
263
|
+
if has_bracket_matches:
|
|
264
|
+
for block in RE_SPLIT_MATCH_BLOCKS.split(content):
|
|
265
|
+
if not RE_BRACKET_MATCHES.search(block):
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
match_time = _extract_match_time(block, log_date)
|
|
269
|
+
session_id = _extract_session_id(block)
|
|
270
|
+
encounters.extend(_parse_player_sections(block, match_time, session_id, file_path))
|
|
271
|
+
|
|
272
|
+
elif has_player_sections:
|
|
273
|
+
session_id = _extract_session_id(content)
|
|
274
|
+
encounters.extend(_parse_player_sections(content, log_date, session_id, file_path))
|
|
275
|
+
|
|
276
|
+
elif has_plain_matches or has_tabular_header:
|
|
277
|
+
for block in RE_SPLIT_PLAIN_MATCHES.split(content):
|
|
278
|
+
if not block.strip():
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
match_time = _extract_match_time(block, log_date)
|
|
282
|
+
session_id = _extract_session_id(block)
|
|
283
|
+
encounters.extend(_parse_tabular_block(block, match_time, session_id, file_path, spfn=is_spfn_log))
|
|
284
|
+
|
|
285
|
+
return encounters
|
|
Binary file
|
|
Binary file
|