recently-splatted-with 1.0.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.
Files changed (23) hide show
  1. recently_splatted_with-1.0.0/LICENSE +19 -0
  2. recently_splatted_with-1.0.0/PKG-INFO +93 -0
  3. recently_splatted_with-1.0.0/README.md +76 -0
  4. recently_splatted_with-1.0.0/pyproject.toml +36 -0
  5. recently_splatted_with-1.0.0/setup.cfg +4 -0
  6. recently_splatted_with-1.0.0/src/recently_splatted_with/__init__.py +1 -0
  7. recently_splatted_with-1.0.0/src/recently_splatted_with/__main__.py +7 -0
  8. recently_splatted_with-1.0.0/src/recently_splatted_with/data_models.py +61 -0
  9. recently_splatted_with-1.0.0/src/recently_splatted_with/main.py +56 -0
  10. recently_splatted_with-1.0.0/src/recently_splatted_with/mii_fetcher.py +263 -0
  11. recently_splatted_with-1.0.0/src/recently_splatted_with/parser.py +285 -0
  12. recently_splatted_with-1.0.0/src/recently_splatted_with/resources/icon.ico +0 -0
  13. recently_splatted_with-1.0.0/src/recently_splatted_with/resources/icon.png +0 -0
  14. recently_splatted_with-1.0.0/src/recently_splatted_with/themes.py +300 -0
  15. recently_splatted_with-1.0.0/src/recently_splatted_with/ui/__init__.py +0 -0
  16. recently_splatted_with-1.0.0/src/recently_splatted_with/ui/main_window.py +336 -0
  17. recently_splatted_with-1.0.0/src/recently_splatted_with/ui/player_card.py +137 -0
  18. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/PKG-INFO +93 -0
  19. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/SOURCES.txt +21 -0
  20. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/dependency_links.txt +1 -0
  21. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/entry_points.txt +3 -0
  22. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/requires.txt +4 -0
  23. recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Shadow Doggo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: recently-splatted-with
3
+ Version: 1.0.0
4
+ Summary: GUI application to view a list of players you've recently encountered in Splatoon matches using Splatlogger logs
5
+ Author-email: Shadow Doggo <shadowdoggo@protonmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: PySide6>=6.5.0
13
+ Requires-Dist: requests>=2.28.0
14
+ Requires-Dist: userpaths>=0.1.3
15
+ Requires-Dist: pyshortcuts>=1.9.1
16
+ Dynamic: license-file
17
+
18
+ # RecentlySplattedWith
19
+
20
+ A GUI application to view a list of players you've recently encountered in Splatoon matches using [Splatlogger](https://codeberg.org/ShadowDoggo/Splatlogger) logs.
21
+
22
+ <img src="assets/UI_Screenshot.png" width="600" alt="(UI Screenshot)">
23
+
24
+ ## Download / Installation
25
+ ### Standalone executable
26
+ Windows and Linux executables are provided on the Releases page.
27
+ These are portable and don't require you to have Python installed.
28
+
29
+ On Linux, you might need to make the file executable first with `chmod +x path/to/executable`. You might also need to install `libxcb-cursor` / `xcb-util-cursor` if you get `qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin` upon launching the app.
30
+
31
+ ### Python package
32
+ You can install RecentlySplattedWith as a package from PyPI:
33
+ ```
34
+ pip install RecentlySplattedWith
35
+ ```
36
+
37
+ Then run with:
38
+ ```
39
+ RecentlySplattedWith [--create-shortcut]
40
+ ```
41
+ (if that fails, try `python -m recently_splatted_with`)
42
+ Use `--create-shortcut` to create an aplication shortcut.
43
+
44
+ On Linux, you will need to install `libxcb-cursor` / `xcb-util-cursor` if you get `qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin`
45
+ upon lanching the app.
46
+
47
+ To run from source instead, download the latest source archive from the Releases
48
+ page and extract it, then open a terminal window in the extracted directory.
49
+
50
+ Install the required dependencies:
51
+ ```
52
+ pip install -r requirements.txt
53
+ ```
54
+
55
+ Then move into the `src` directory and run:
56
+ ```
57
+ python -m recently_splatted_with
58
+ ```
59
+
60
+ ## Usage
61
+ If you have not used [Splatlogger](https://codeberg.org/ShadowDoggo/Splatlogger) before, you will need to set it up and log a few matches beforehand. Using auto-logging is recommended.
62
+
63
+ When you launch the app, it will automatically load all logs from `Documents/Splatlogger/logs/`. You can also manually choose a log directory or log file(s).
64
+
65
+ Use the `Exclude (PID/PNID):` field to hide yourself from the list.
66
+
67
+ You can search for individual players using the search field and change the sorting category in the `Sort by:` field. The `Max entries:` feild defines the maximum number of players displayed in the list.
68
+
69
+ ## Building the executable with PyInstaller
70
+ Set up a virtual environment and install PyInstaller as well as the required dependencies.
71
+
72
+ In `main.py`, replace
73
+ ```py
74
+ icon_path = resources.files(__package__).joinpath("resources", "icon.png")
75
+ ```
76
+
77
+ with
78
+ ```py
79
+ icon_path = Path(sys._MEIPASS) / "resources/icon.png"
80
+ ```
81
+
82
+ Then run:
83
+ ```
84
+ pyinstaller --noconfirm --onefile --windowed --name "RecentlySplattedWith-v1.x" --upx-dir "path/to/upx" --optimize "2" --add-data "path/to/RecentlySplattedWith/src/recently_splatted_with/resources:resources/" --icon "path/to/icon.ico" "path/to/RecentlySplattedWith/src/recently_splatted_with/__main__.py"
85
+ ```
86
+ replacing the placeholder paths with the actual ones.
87
+
88
+ You can omit `--upx-dir` if you're not using UPX to compress the executable and `--optimize "2"` to disable bytecode optimization.
89
+
90
+ ## License
91
+ This project is licensed under the [MIT License](LICENSE).
92
+
93
+ The executables include third-party components; their license texts are contained in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
@@ -0,0 +1,76 @@
1
+ # RecentlySplattedWith
2
+
3
+ A GUI application to view a list of players you've recently encountered in Splatoon matches using [Splatlogger](https://codeberg.org/ShadowDoggo/Splatlogger) logs.
4
+
5
+ <img src="assets/UI_Screenshot.png" width="600" alt="(UI Screenshot)">
6
+
7
+ ## Download / Installation
8
+ ### Standalone executable
9
+ Windows and Linux executables are provided on the Releases page.
10
+ These are portable and don't require you to have Python installed.
11
+
12
+ On Linux, you might need to make the file executable first with `chmod +x path/to/executable`. You might also need to install `libxcb-cursor` / `xcb-util-cursor` if you get `qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin` upon launching the app.
13
+
14
+ ### Python package
15
+ You can install RecentlySplattedWith as a package from PyPI:
16
+ ```
17
+ pip install RecentlySplattedWith
18
+ ```
19
+
20
+ Then run with:
21
+ ```
22
+ RecentlySplattedWith [--create-shortcut]
23
+ ```
24
+ (if that fails, try `python -m recently_splatted_with`)
25
+ Use `--create-shortcut` to create an aplication shortcut.
26
+
27
+ On Linux, you will need to install `libxcb-cursor` / `xcb-util-cursor` if you get `qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin`
28
+ upon lanching the app.
29
+
30
+ To run from source instead, download the latest source archive from the Releases
31
+ page and extract it, then open a terminal window in the extracted directory.
32
+
33
+ Install the required dependencies:
34
+ ```
35
+ pip install -r requirements.txt
36
+ ```
37
+
38
+ Then move into the `src` directory and run:
39
+ ```
40
+ python -m recently_splatted_with
41
+ ```
42
+
43
+ ## Usage
44
+ If you have not used [Splatlogger](https://codeberg.org/ShadowDoggo/Splatlogger) before, you will need to set it up and log a few matches beforehand. Using auto-logging is recommended.
45
+
46
+ When you launch the app, it will automatically load all logs from `Documents/Splatlogger/logs/`. You can also manually choose a log directory or log file(s).
47
+
48
+ Use the `Exclude (PID/PNID):` field to hide yourself from the list.
49
+
50
+ You can search for individual players using the search field and change the sorting category in the `Sort by:` field. The `Max entries:` feild defines the maximum number of players displayed in the list.
51
+
52
+ ## Building the executable with PyInstaller
53
+ Set up a virtual environment and install PyInstaller as well as the required dependencies.
54
+
55
+ In `main.py`, replace
56
+ ```py
57
+ icon_path = resources.files(__package__).joinpath("resources", "icon.png")
58
+ ```
59
+
60
+ with
61
+ ```py
62
+ icon_path = Path(sys._MEIPASS) / "resources/icon.png"
63
+ ```
64
+
65
+ Then run:
66
+ ```
67
+ pyinstaller --noconfirm --onefile --windowed --name "RecentlySplattedWith-v1.x" --upx-dir "path/to/upx" --optimize "2" --add-data "path/to/RecentlySplattedWith/src/recently_splatted_with/resources:resources/" --icon "path/to/icon.ico" "path/to/RecentlySplattedWith/src/recently_splatted_with/__main__.py"
68
+ ```
69
+ replacing the placeholder paths with the actual ones.
70
+
71
+ You can omit `--upx-dir` if you're not using UPX to compress the executable and `--optimize "2"` to disable bytecode optimization.
72
+
73
+ ## License
74
+ This project is licensed under the [MIT License](LICENSE).
75
+
76
+ The executables include third-party components; their license texts are contained in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=83.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "recently-splatted-with"
7
+ version = "1.0.0"
8
+ description = "GUI application to view a list of players you've recently encountered in Splatoon matches using Splatlogger logs"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Shadow Doggo", email = "shadowdoggo@protonmail.com" }
12
+ ]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "PySide6>=6.5.0",
16
+ "requests>=2.28.0",
17
+ "userpaths>=0.1.3",
18
+ "pyshortcuts>=1.9.1"
19
+ ]
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "Operating System :: OS Independent",
23
+ ]
24
+ license = "MIT"
25
+ license-files = ["LICENSE"]
26
+
27
+ [project.gui-scripts]
28
+ RecentlySplattedWith = "recently_splatted_with.main:main"
29
+ recently-splatted-with = "recently_splatted_with.main:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+ include = ["recently_splatted_with*"]
34
+
35
+ [tool.setuptools.package-data]
36
+ recently_splatted_with = ["resources/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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