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.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,7 @@
1
+ try:
2
+ from .main import main
3
+ except ImportError:
4
+ from recently_splatted_with.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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