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.
- recently_splatted_with-1.0.0/LICENSE +19 -0
- recently_splatted_with-1.0.0/PKG-INFO +93 -0
- recently_splatted_with-1.0.0/README.md +76 -0
- recently_splatted_with-1.0.0/pyproject.toml +36 -0
- recently_splatted_with-1.0.0/setup.cfg +4 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/__init__.py +1 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/__main__.py +7 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/data_models.py +61 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/main.py +56 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/mii_fetcher.py +263 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/parser.py +285 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/resources/icon.ico +0 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/resources/icon.png +0 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/themes.py +300 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/ui/__init__.py +0 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/ui/main_window.py +336 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with/ui/player_card.py +137 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/PKG-INFO +93 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/SOURCES.txt +21 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/dependency_links.txt +1 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/entry_points.txt +3 -0
- recently_splatted_with-1.0.0/src/recently_splatted_with.egg-info/requires.txt +4 -0
- 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 @@
|
|
|
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
|