jietng 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jietng-0.1.0/LICENSE +21 -0
- jietng-0.1.0/PKG-INFO +154 -0
- jietng-0.1.0/README.md +123 -0
- jietng-0.1.0/pyproject.toml +46 -0
- jietng-0.1.0/setup.cfg +4 -0
- jietng-0.1.0/src/jietng/__init__.py +55 -0
- jietng-0.1.0/src/jietng/_http.py +78 -0
- jietng-0.1.0/src/jietng/async_client.py +235 -0
- jietng-0.1.0/src/jietng/client.py +290 -0
- jietng-0.1.0/src/jietng/exceptions.py +94 -0
- jietng-0.1.0/src/jietng/py.typed +0 -0
- jietng-0.1.0/src/jietng.egg-info/PKG-INFO +154 -0
- jietng-0.1.0/src/jietng.egg-info/SOURCES.txt +14 -0
- jietng-0.1.0/src/jietng.egg-info/dependency_links.txt +1 -0
- jietng-0.1.0/src/jietng.egg-info/requires.txt +1 -0
- jietng-0.1.0/src/jietng.egg-info/top_level.txt +1 -0
jietng-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matsuki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jietng-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jietng
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the JiETNG maimai DX (舞萌DX) score management API.
|
|
5
|
+
Author-email: Matsuki <matsuk1@proton.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://jietng.matsuk1.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Matsuk1/JiETNG
|
|
9
|
+
Project-URL: Documentation, https://jietng.matsuk1.com/developer-api
|
|
10
|
+
Project-URL: Issues, https://github.com/Matsuk1/JiETNG/issues
|
|
11
|
+
Keywords: maimai,maimai-dx,舞萌DX,舞萌查分器,查分器,rating-calculator,best-50,b50,score-tracker,line-bot,jietng
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Games/Entertainment
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: httpx>=0.25
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# jietng — Python SDK
|
|
33
|
+
|
|
34
|
+
[`pip install jietng`](https://pypi.org/project/jietng/) — Python 客户端,封装 [JiETNG](https://jietng.matsuk1.com) 舞萌DX 查分器的 HTTP API。
|
|
35
|
+
|
|
36
|
+
支持同步 / 异步两套客户端、覆盖全部 v2 端点(用户 / 权限 / 同步 / 搜歌 / 成绩图 / 导出 …)、类型注解齐全。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install jietng
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
需要 Python ≥ 3.8、httpx ≥ 0.25。
|
|
45
|
+
|
|
46
|
+
## 获取 Token
|
|
47
|
+
|
|
48
|
+
JiETNG API 通过 Bearer Token 鉴权。申请方式见 <https://jietng.matsuk1.com/developer-api> —— 发邮件到 `matsuk1@proton.me` 索取。
|
|
49
|
+
|
|
50
|
+
## 快速开始
|
|
51
|
+
|
|
52
|
+
### 同步
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from jietng import jietngClient
|
|
56
|
+
|
|
57
|
+
with jietngClient(token="your_token") as client:
|
|
58
|
+
users = client.users.list()
|
|
59
|
+
print(users["count"], "registered users")
|
|
60
|
+
|
|
61
|
+
# 取 B50 成绩图(返回 PNG bytes)
|
|
62
|
+
png = client.images.records("U1234567890", command="b50")
|
|
63
|
+
with open("b50.png", "wb") as f:
|
|
64
|
+
f.write(png)
|
|
65
|
+
|
|
66
|
+
# 触发后台同步
|
|
67
|
+
task = client.users.trigger_sync("U1234567890")
|
|
68
|
+
print("queued:", task["task_id"])
|
|
69
|
+
|
|
70
|
+
# 导出成绩为 JSON / XML,文件名由服务端推荐(含玩家名 + 时间戳)
|
|
71
|
+
path = client.exports.save("U1234567890", fmt="json")
|
|
72
|
+
print("exported to", path)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 异步
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from jietng import AsyncjietngClient
|
|
80
|
+
|
|
81
|
+
async def main():
|
|
82
|
+
async with AsyncjietngClient(token="your_token") as client:
|
|
83
|
+
print(await client.songs.search("PANDORA", ver="jp", max_results=3))
|
|
84
|
+
png = await client.images.plate("U1234567890", title="真神")
|
|
85
|
+
with open("plate.png", "wb") as f:
|
|
86
|
+
f.write(png)
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 资源总览
|
|
92
|
+
|
|
93
|
+
| 命名空间 | 主要方法 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `client.users` | `list / get / create / delete / trigger_sync / bind / update_bind / get_rebind_url / get_settings_url` |
|
|
96
|
+
| `client.permissions` | `request / list_requests / accept / reject / revoke / revoke_self` |
|
|
97
|
+
| `client.songs` | `search / info` |
|
|
98
|
+
| `client.tasks` | `get` |
|
|
99
|
+
| `client.versions` | `list` |
|
|
100
|
+
| `client.dxdata` | `get` |
|
|
101
|
+
| `client.images` | `user_song / records / plate / achievement` |
|
|
102
|
+
| `client.exports` | `download / save` |
|
|
103
|
+
|
|
104
|
+
所有方法的形参 / 返回结构与 [JiETNG API 文档](https://jietng.matsuk1.com/developer-api) 一一对应。
|
|
105
|
+
|
|
106
|
+
## 错误处理
|
|
107
|
+
|
|
108
|
+
所有 HTTP 非 2xx 状态都会抛 `APIError` 子类。可以按需 catch 具体类型:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from jietng import jietngClient, NotFoundError, PermissionDeniedError, RateLimitedError, QueueFullError
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
png = client.images.records("U_unknown", command="b50")
|
|
115
|
+
except NotFoundError:
|
|
116
|
+
print("user has no records yet")
|
|
117
|
+
except PermissionDeniedError:
|
|
118
|
+
print("your token doesn't have access to this user")
|
|
119
|
+
except RateLimitedError:
|
|
120
|
+
print("slow down")
|
|
121
|
+
except QueueFullError:
|
|
122
|
+
print("server task queue is full, retry later")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
完整异常层级:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
jietngError # 基类
|
|
129
|
+
└─ APIError # 任意 HTTP 非 2xx
|
|
130
|
+
├─ ValidationError # 400
|
|
131
|
+
├─ AuthenticationError # 401
|
|
132
|
+
├─ PermissionDeniedError # 403
|
|
133
|
+
├─ NotFoundError # 404
|
|
134
|
+
├─ RateLimitedError # 429
|
|
135
|
+
├─ ServerError # 500
|
|
136
|
+
└─ QueueFullError # 503
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
每个异常实例都带 `status_code` / `error` / `message` / `payload` 字段,方便排查。
|
|
140
|
+
|
|
141
|
+
## 自定义
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
client = jietngClient(
|
|
145
|
+
token="your_token",
|
|
146
|
+
base_url="https://your-self-hosted.example.com/api/v2", # 自托管时
|
|
147
|
+
timeout=60.0,
|
|
148
|
+
extra_headers={"X-App-Name": "MyBot"},
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 许可
|
|
153
|
+
|
|
154
|
+
MIT。代码:<https://github.com/Matsuk1/JiETNG/tree/main/client>
|
jietng-0.1.0/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# jietng — Python SDK
|
|
2
|
+
|
|
3
|
+
[`pip install jietng`](https://pypi.org/project/jietng/) — Python 客户端,封装 [JiETNG](https://jietng.matsuk1.com) 舞萌DX 查分器的 HTTP API。
|
|
4
|
+
|
|
5
|
+
支持同步 / 异步两套客户端、覆盖全部 v2 端点(用户 / 权限 / 同步 / 搜歌 / 成绩图 / 导出 …)、类型注解齐全。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install jietng
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
需要 Python ≥ 3.8、httpx ≥ 0.25。
|
|
14
|
+
|
|
15
|
+
## 获取 Token
|
|
16
|
+
|
|
17
|
+
JiETNG API 通过 Bearer Token 鉴权。申请方式见 <https://jietng.matsuk1.com/developer-api> —— 发邮件到 `matsuk1@proton.me` 索取。
|
|
18
|
+
|
|
19
|
+
## 快速开始
|
|
20
|
+
|
|
21
|
+
### 同步
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from jietng import jietngClient
|
|
25
|
+
|
|
26
|
+
with jietngClient(token="your_token") as client:
|
|
27
|
+
users = client.users.list()
|
|
28
|
+
print(users["count"], "registered users")
|
|
29
|
+
|
|
30
|
+
# 取 B50 成绩图(返回 PNG bytes)
|
|
31
|
+
png = client.images.records("U1234567890", command="b50")
|
|
32
|
+
with open("b50.png", "wb") as f:
|
|
33
|
+
f.write(png)
|
|
34
|
+
|
|
35
|
+
# 触发后台同步
|
|
36
|
+
task = client.users.trigger_sync("U1234567890")
|
|
37
|
+
print("queued:", task["task_id"])
|
|
38
|
+
|
|
39
|
+
# 导出成绩为 JSON / XML,文件名由服务端推荐(含玩家名 + 时间戳)
|
|
40
|
+
path = client.exports.save("U1234567890", fmt="json")
|
|
41
|
+
print("exported to", path)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 异步
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from jietng import AsyncjietngClient
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
async with AsyncjietngClient(token="your_token") as client:
|
|
52
|
+
print(await client.songs.search("PANDORA", ver="jp", max_results=3))
|
|
53
|
+
png = await client.images.plate("U1234567890", title="真神")
|
|
54
|
+
with open("plate.png", "wb") as f:
|
|
55
|
+
f.write(png)
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 资源总览
|
|
61
|
+
|
|
62
|
+
| 命名空间 | 主要方法 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `client.users` | `list / get / create / delete / trigger_sync / bind / update_bind / get_rebind_url / get_settings_url` |
|
|
65
|
+
| `client.permissions` | `request / list_requests / accept / reject / revoke / revoke_self` |
|
|
66
|
+
| `client.songs` | `search / info` |
|
|
67
|
+
| `client.tasks` | `get` |
|
|
68
|
+
| `client.versions` | `list` |
|
|
69
|
+
| `client.dxdata` | `get` |
|
|
70
|
+
| `client.images` | `user_song / records / plate / achievement` |
|
|
71
|
+
| `client.exports` | `download / save` |
|
|
72
|
+
|
|
73
|
+
所有方法的形参 / 返回结构与 [JiETNG API 文档](https://jietng.matsuk1.com/developer-api) 一一对应。
|
|
74
|
+
|
|
75
|
+
## 错误处理
|
|
76
|
+
|
|
77
|
+
所有 HTTP 非 2xx 状态都会抛 `APIError` 子类。可以按需 catch 具体类型:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from jietng import jietngClient, NotFoundError, PermissionDeniedError, RateLimitedError, QueueFullError
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
png = client.images.records("U_unknown", command="b50")
|
|
84
|
+
except NotFoundError:
|
|
85
|
+
print("user has no records yet")
|
|
86
|
+
except PermissionDeniedError:
|
|
87
|
+
print("your token doesn't have access to this user")
|
|
88
|
+
except RateLimitedError:
|
|
89
|
+
print("slow down")
|
|
90
|
+
except QueueFullError:
|
|
91
|
+
print("server task queue is full, retry later")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
完整异常层级:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
jietngError # 基类
|
|
98
|
+
└─ APIError # 任意 HTTP 非 2xx
|
|
99
|
+
├─ ValidationError # 400
|
|
100
|
+
├─ AuthenticationError # 401
|
|
101
|
+
├─ PermissionDeniedError # 403
|
|
102
|
+
├─ NotFoundError # 404
|
|
103
|
+
├─ RateLimitedError # 429
|
|
104
|
+
├─ ServerError # 500
|
|
105
|
+
└─ QueueFullError # 503
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
每个异常实例都带 `status_code` / `error` / `message` / `payload` 字段,方便排查。
|
|
109
|
+
|
|
110
|
+
## 自定义
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
client = jietngClient(
|
|
114
|
+
token="your_token",
|
|
115
|
+
base_url="https://your-self-hosted.example.com/api/v2", # 自托管时
|
|
116
|
+
timeout=60.0,
|
|
117
|
+
extra_headers={"X-App-Name": "MyBot"},
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 许可
|
|
122
|
+
|
|
123
|
+
MIT。代码:<https://github.com/Matsuk1/JiETNG/tree/main/client>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jietng"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the JiETNG maimai DX (舞萌DX) score management API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Matsuki", email = "matsuk1@proton.me" }]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
dependencies = ["httpx>=0.25"]
|
|
14
|
+
keywords = [
|
|
15
|
+
"maimai", "maimai-dx", "舞萌DX", "舞萌查分器", "查分器",
|
|
16
|
+
"rating-calculator", "best-50", "b50", "score-tracker",
|
|
17
|
+
"line-bot", "jietng",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.8",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: Games/Entertainment",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://jietng.matsuk1.com"
|
|
38
|
+
Repository = "https://github.com/Matsuk1/JiETNG"
|
|
39
|
+
Documentation = "https://jietng.matsuk1.com/developer-api"
|
|
40
|
+
Issues = "https://github.com/Matsuk1/JiETNG/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
jietng = ["py.typed"]
|
jietng-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""jietng — Python SDK for the JiETNG maimai DX score management API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from jietng import jietngClient
|
|
6
|
+
|
|
7
|
+
client = jietngClient(token="your_token_here")
|
|
8
|
+
users = client.users.list()
|
|
9
|
+
b50_png = client.images.records("U1234567890", command="b50")
|
|
10
|
+
|
|
11
|
+
Async usage::
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from jietng import AsyncjietngClient
|
|
15
|
+
|
|
16
|
+
async def main():
|
|
17
|
+
async with AsyncjietngClient(token="your_token_here") as client:
|
|
18
|
+
png = await client.images.records("U1234567890", command="b50")
|
|
19
|
+
|
|
20
|
+
asyncio.run(main())
|
|
21
|
+
|
|
22
|
+
See https://jietng.matsuk1.com/developer-api for API details and how to
|
|
23
|
+
obtain an access token.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
from .client import jietngClient
|
|
29
|
+
from .async_client import AsyncjietngClient
|
|
30
|
+
from .exceptions import (
|
|
31
|
+
APIError,
|
|
32
|
+
AuthenticationError,
|
|
33
|
+
jietngError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
PermissionDeniedError,
|
|
36
|
+
QueueFullError,
|
|
37
|
+
RateLimitedError,
|
|
38
|
+
ServerError,
|
|
39
|
+
ValidationError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
"jietngClient",
|
|
45
|
+
"AsyncjietngClient",
|
|
46
|
+
"jietngError",
|
|
47
|
+
"APIError",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"PermissionDeniedError",
|
|
50
|
+
"NotFoundError",
|
|
51
|
+
"ValidationError",
|
|
52
|
+
"RateLimitedError",
|
|
53
|
+
"ServerError",
|
|
54
|
+
"QueueFullError",
|
|
55
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""HTTP 底层 / Shared HTTP helpers for sync & async clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .exceptions import APIError, from_response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://jietng-endpoint.matsuk1.com/api/v2"
|
|
13
|
+
DEFAULT_TIMEOUT = 30.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_headers(token: str, extra: Optional[Mapping[str, str]] = None) -> dict:
|
|
17
|
+
headers = {
|
|
18
|
+
"Authorization": f"Bearer {token}",
|
|
19
|
+
"User-Agent": f"jietng-py-sdk/{__version__}",
|
|
20
|
+
"Accept": "application/json",
|
|
21
|
+
}
|
|
22
|
+
if extra:
|
|
23
|
+
headers.update(extra)
|
|
24
|
+
return headers
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_json_safe(resp: httpx.Response) -> Any:
|
|
28
|
+
"""尽量返回 JSON;失败返回原始 bytes 或文本字符串。"""
|
|
29
|
+
ctype = (resp.headers.get("content-type") or "").lower()
|
|
30
|
+
if "application/json" in ctype:
|
|
31
|
+
try:
|
|
32
|
+
return resp.json()
|
|
33
|
+
except Exception:
|
|
34
|
+
return resp.text
|
|
35
|
+
if ctype.startswith(("image/", "application/octet-stream", "application/xml")):
|
|
36
|
+
return resp.content
|
|
37
|
+
if "text/" in ctype:
|
|
38
|
+
return resp.text
|
|
39
|
+
# 兜底:尝试 JSON,否则原始字节
|
|
40
|
+
try:
|
|
41
|
+
return resp.json()
|
|
42
|
+
except Exception:
|
|
43
|
+
return resp.content
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_response(resp: httpx.Response) -> Any:
|
|
47
|
+
"""成功 → 返回解析后的内容;失败 → 抛对应 APIError 子类。"""
|
|
48
|
+
payload = _parse_json_safe(resp)
|
|
49
|
+
if 200 <= resp.status_code < 300:
|
|
50
|
+
return payload
|
|
51
|
+
raise from_response(resp.status_code, payload)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _binary_response(resp: httpx.Response) -> bytes:
|
|
55
|
+
"""专给图片/导出文件用:成功返回 bytes,失败仍抛对应异常。"""
|
|
56
|
+
if 200 <= resp.status_code < 300:
|
|
57
|
+
return resp.content
|
|
58
|
+
payload = _parse_json_safe(resp)
|
|
59
|
+
raise from_response(resp.status_code, payload)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _attachment_filename(resp: httpx.Response) -> Optional[str]:
|
|
63
|
+
"""从 Content-Disposition 头里解析 filename / filename* (RFC 6266)。"""
|
|
64
|
+
cd = resp.headers.get("content-disposition", "")
|
|
65
|
+
if not cd:
|
|
66
|
+
return None
|
|
67
|
+
# filename*=UTF-8''XXX 优先(CJK 安全)
|
|
68
|
+
for part in cd.split(";"):
|
|
69
|
+
part = part.strip()
|
|
70
|
+
if part.lower().startswith("filename*=utf-8''"):
|
|
71
|
+
from urllib.parse import unquote
|
|
72
|
+
return unquote(part.split("''", 1)[1])
|
|
73
|
+
for part in cd.split(";"):
|
|
74
|
+
part = part.strip()
|
|
75
|
+
if part.lower().startswith("filename="):
|
|
76
|
+
value = part[len("filename="):].strip('"')
|
|
77
|
+
return value
|
|
78
|
+
return None
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""异步客户端 / Async client(与 sync 客户端 API 形态完全一致,方法都 async)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._http import (
|
|
9
|
+
DEFAULT_BASE_URL,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
_attachment_filename,
|
|
12
|
+
_binary_response,
|
|
13
|
+
_build_headers,
|
|
14
|
+
_check_response,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _BaseAsyncResource:
|
|
19
|
+
__slots__ = ("_client",)
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: "AsyncjietngClient"):
|
|
22
|
+
self._client = client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncUsersResource(_BaseAsyncResource):
|
|
26
|
+
async def list(self) -> dict:
|
|
27
|
+
return await self._client._request("GET", "/users")
|
|
28
|
+
|
|
29
|
+
async def get(self, user_id: str) -> dict:
|
|
30
|
+
return await self._client._request("GET", f"/users/{user_id}")
|
|
31
|
+
|
|
32
|
+
async def create(self, **fields: Any) -> dict:
|
|
33
|
+
return await self._client._request("POST", "/users", json=fields)
|
|
34
|
+
|
|
35
|
+
async def delete(self, user_id: str) -> dict:
|
|
36
|
+
return await self._client._request("DELETE", f"/users/{user_id}")
|
|
37
|
+
|
|
38
|
+
async def trigger_sync(self, user_id: str) -> dict:
|
|
39
|
+
return await self._client._request("POST", f"/users/{user_id}/tasks")
|
|
40
|
+
|
|
41
|
+
async def get_rebind_url(self, user_id: str) -> dict:
|
|
42
|
+
return await self._client._request("GET", f"/users/{user_id}/rebind-url")
|
|
43
|
+
|
|
44
|
+
async def get_settings_url(self, user_id: str) -> dict:
|
|
45
|
+
return await self._client._request("GET", f"/users/{user_id}/settings-url")
|
|
46
|
+
|
|
47
|
+
async def bind(self, user_id: str, **fields: Any) -> dict:
|
|
48
|
+
return await self._client._request("POST", f"/users/{user_id}/bind", json=fields)
|
|
49
|
+
|
|
50
|
+
async def update_bind(self, user_id: str, **fields: Any) -> dict:
|
|
51
|
+
return await self._client._request("PUT", f"/users/{user_id}/bind", json=fields)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AsyncPermissionsResource(_BaseAsyncResource):
|
|
55
|
+
async def request(self, user_id: str, requester_name: Optional[str] = None) -> dict:
|
|
56
|
+
body = {"requester_name": requester_name} if requester_name else {}
|
|
57
|
+
return await self._client._request("POST", f"/users/{user_id}/permissions", json=body)
|
|
58
|
+
|
|
59
|
+
async def list_requests(self, user_id: str) -> dict:
|
|
60
|
+
return await self._client._request("GET", f"/users/{user_id}/permissions/requests")
|
|
61
|
+
|
|
62
|
+
async def accept(self, user_id: str, request_id: str) -> dict:
|
|
63
|
+
return await self._client._request(
|
|
64
|
+
"PATCH",
|
|
65
|
+
f"/users/{user_id}/permissions/requests/{request_id}",
|
|
66
|
+
json={"action": "accept"},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def reject(self, user_id: str, request_id: str) -> dict:
|
|
70
|
+
return await self._client._request(
|
|
71
|
+
"PATCH",
|
|
72
|
+
f"/users/{user_id}/permissions/requests/{request_id}",
|
|
73
|
+
json={"action": "reject"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def revoke(self, user_id: str, token_id: str) -> dict:
|
|
77
|
+
return await self._client._request("DELETE", f"/users/{user_id}/permissions/{token_id}")
|
|
78
|
+
|
|
79
|
+
async def revoke_self(self, user_id: str) -> dict:
|
|
80
|
+
return await self._client._request("DELETE", f"/users/{user_id}/permissions/self")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AsyncSongsResource(_BaseAsyncResource):
|
|
84
|
+
async def search(
|
|
85
|
+
self,
|
|
86
|
+
q: str,
|
|
87
|
+
ver: str = "jp",
|
|
88
|
+
max_results: int = 6,
|
|
89
|
+
user_id: Optional[str] = None,
|
|
90
|
+
) -> dict:
|
|
91
|
+
params = {"q": q, "ver": ver, "max_results": max_results}
|
|
92
|
+
if user_id:
|
|
93
|
+
params["user_id"] = user_id
|
|
94
|
+
return await self._client._request("GET", "/songs/search", params=params)
|
|
95
|
+
|
|
96
|
+
async def info(self, song_id: str) -> bytes:
|
|
97
|
+
return await self._client._request("GET", f"/songs/{song_id}/image", binary=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AsyncTasksResource(_BaseAsyncResource):
|
|
101
|
+
async def get(self, task_id: str) -> dict:
|
|
102
|
+
return await self._client._request("GET", f"/tasks/{task_id}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AsyncVersionsResource(_BaseAsyncResource):
|
|
106
|
+
async def list(self) -> dict:
|
|
107
|
+
return await self._client._request("GET", "/versions")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AsyncDxdataResource(_BaseAsyncResource):
|
|
111
|
+
async def get(self) -> dict:
|
|
112
|
+
return await self._client._request("GET", "/dxdata")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AsyncImagesResource(_BaseAsyncResource):
|
|
116
|
+
async def user_song(self, user_id: str, song_id: str) -> bytes:
|
|
117
|
+
return await self._client._request(
|
|
118
|
+
"GET", f"/users/{user_id}/songs/{song_id}/image", binary=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def records(self, user_id: str, command: str = "b50") -> bytes:
|
|
122
|
+
return await self._client._request(
|
|
123
|
+
"GET", f"/users/{user_id}/image",
|
|
124
|
+
params={"command": command}, binary=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def plate(self, user_id: str, title: str, filter_mode: Optional[str] = None) -> bytes:
|
|
128
|
+
params: dict = {"title": title}
|
|
129
|
+
if filter_mode:
|
|
130
|
+
params["filter"] = filter_mode
|
|
131
|
+
return await self._client._request(
|
|
132
|
+
"GET", f"/users/{user_id}/plate", params=params, binary=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def achievement(
|
|
136
|
+
self,
|
|
137
|
+
user_id: str,
|
|
138
|
+
level: str,
|
|
139
|
+
rank: Optional[str] = None,
|
|
140
|
+
filter_mode: Optional[str] = None,
|
|
141
|
+
) -> bytes:
|
|
142
|
+
params: dict = {"level": level}
|
|
143
|
+
if rank:
|
|
144
|
+
params["rank"] = rank
|
|
145
|
+
if filter_mode:
|
|
146
|
+
params["filter"] = filter_mode
|
|
147
|
+
return await self._client._request(
|
|
148
|
+
"GET", f"/users/{user_id}/achievement", params=params, binary=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncExportsResource(_BaseAsyncResource):
|
|
153
|
+
async def download(self, user_id: str, fmt: str = "json") -> Tuple[bytes, Optional[str]]:
|
|
154
|
+
return await self._client._request_with_filename(
|
|
155
|
+
"GET", f"/users/{user_id}/export", params={"fmt": fmt},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def save(self, user_id: str, fmt: str = "json", path: Optional[str] = None) -> str:
|
|
159
|
+
content, suggested = await self.download(user_id, fmt=fmt)
|
|
160
|
+
target = path or suggested or f"jietng-{user_id}.{fmt}"
|
|
161
|
+
with open(target, "wb") as f:
|
|
162
|
+
f.write(content)
|
|
163
|
+
return target
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ============================================================
|
|
167
|
+
# 主 async client
|
|
168
|
+
# ============================================================
|
|
169
|
+
|
|
170
|
+
class AsyncjietngClient:
|
|
171
|
+
"""异步 JiETNG API 客户端。
|
|
172
|
+
|
|
173
|
+
Example::
|
|
174
|
+
|
|
175
|
+
import asyncio
|
|
176
|
+
from jietng import AsyncjietngClient
|
|
177
|
+
|
|
178
|
+
async def main():
|
|
179
|
+
async with AsyncjietngClient(token="your_token") as client:
|
|
180
|
+
users = await client.users.list()
|
|
181
|
+
png = await client.images.records("U123", command="b50")
|
|
182
|
+
|
|
183
|
+
asyncio.run(main())
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
token: str,
|
|
189
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
190
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
191
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
192
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
193
|
+
):
|
|
194
|
+
if not token:
|
|
195
|
+
raise ValueError("token is required")
|
|
196
|
+
self._token = token
|
|
197
|
+
self._base_url = base_url.rstrip("/")
|
|
198
|
+
self._headers = _build_headers(token, extra_headers)
|
|
199
|
+
self._http = httpx.AsyncClient(
|
|
200
|
+
base_url=self._base_url,
|
|
201
|
+
headers=self._headers,
|
|
202
|
+
timeout=timeout,
|
|
203
|
+
transport=transport,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self.users = AsyncUsersResource(self)
|
|
207
|
+
self.permissions = AsyncPermissionsResource(self)
|
|
208
|
+
self.songs = AsyncSongsResource(self)
|
|
209
|
+
self.tasks = AsyncTasksResource(self)
|
|
210
|
+
self.versions = AsyncVersionsResource(self)
|
|
211
|
+
self.dxdata = AsyncDxdataResource(self)
|
|
212
|
+
self.images = AsyncImagesResource(self)
|
|
213
|
+
self.exports = AsyncExportsResource(self)
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> "AsyncjietngClient":
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
219
|
+
await self.close()
|
|
220
|
+
|
|
221
|
+
async def close(self) -> None:
|
|
222
|
+
await self._http.aclose()
|
|
223
|
+
|
|
224
|
+
async def _request(self, method: str, path: str, *, binary: bool = False, **kwargs: Any) -> Any:
|
|
225
|
+
resp = await self._http.request(method, path, **kwargs)
|
|
226
|
+
if binary:
|
|
227
|
+
return _binary_response(resp)
|
|
228
|
+
return _check_response(resp)
|
|
229
|
+
|
|
230
|
+
async def _request_with_filename(
|
|
231
|
+
self, method: str, path: str, **kwargs: Any,
|
|
232
|
+
) -> Tuple[bytes, Optional[str]]:
|
|
233
|
+
resp = await self._http.request(method, path, **kwargs)
|
|
234
|
+
content = _binary_response(resp)
|
|
235
|
+
return content, _attachment_filename(resp)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""同步客户端 / Sync client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._http import (
|
|
9
|
+
DEFAULT_BASE_URL,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
_attachment_filename,
|
|
12
|
+
_binary_response,
|
|
13
|
+
_build_headers,
|
|
14
|
+
_check_response,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ============================================================
|
|
19
|
+
# Resource 命名空间
|
|
20
|
+
# ============================================================
|
|
21
|
+
|
|
22
|
+
class _BaseResource:
|
|
23
|
+
"""资源基类,仅持有 client 引用。"""
|
|
24
|
+
__slots__ = ("_client",)
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: "jietngClient"):
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsersResource(_BaseResource):
|
|
31
|
+
"""用户相关:列表 / 详情 / 绑定 / 同步 等。"""
|
|
32
|
+
|
|
33
|
+
def list(self) -> dict:
|
|
34
|
+
"""``GET /users`` —— 列出所有已注册用户。"""
|
|
35
|
+
return self._client._request("GET", "/users")
|
|
36
|
+
|
|
37
|
+
def get(self, user_id: str) -> dict:
|
|
38
|
+
"""``GET /users/{user_id}`` —— 取单个用户数据。"""
|
|
39
|
+
return self._client._request("GET", f"/users/{user_id}")
|
|
40
|
+
|
|
41
|
+
def create(self, **fields: Any) -> dict:
|
|
42
|
+
"""``POST /users`` —— 创建用户(管理员 token)。"""
|
|
43
|
+
return self._client._request("POST", "/users", json=fields)
|
|
44
|
+
|
|
45
|
+
def delete(self, user_id: str) -> dict:
|
|
46
|
+
"""``DELETE /users/{user_id}`` —— 删除用户。"""
|
|
47
|
+
return self._client._request("DELETE", f"/users/{user_id}")
|
|
48
|
+
|
|
49
|
+
def trigger_sync(self, user_id: str) -> dict:
|
|
50
|
+
"""``POST /users/{user_id}/tasks`` —— 触发一次 maimai 数据拉取(异步入队)。"""
|
|
51
|
+
return self._client._request("POST", f"/users/{user_id}/tasks")
|
|
52
|
+
|
|
53
|
+
def get_rebind_url(self, user_id: str) -> dict:
|
|
54
|
+
"""``GET /users/{user_id}/rebind-url``"""
|
|
55
|
+
return self._client._request("GET", f"/users/{user_id}/rebind-url")
|
|
56
|
+
|
|
57
|
+
def get_settings_url(self, user_id: str) -> dict:
|
|
58
|
+
"""``GET /users/{user_id}/settings-url``"""
|
|
59
|
+
return self._client._request("GET", f"/users/{user_id}/settings-url")
|
|
60
|
+
|
|
61
|
+
def bind(self, user_id: str, **fields: Any) -> dict:
|
|
62
|
+
"""``POST /users/{user_id}/bind`` —— 首次绑定 SEGA 账号。"""
|
|
63
|
+
return self._client._request("POST", f"/users/{user_id}/bind", json=fields)
|
|
64
|
+
|
|
65
|
+
def update_bind(self, user_id: str, **fields: Any) -> dict:
|
|
66
|
+
"""``PUT /users/{user_id}/bind`` —— 更新已绑定信息。"""
|
|
67
|
+
return self._client._request("PUT", f"/users/{user_id}/bind", json=fields)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PermissionsResource(_BaseResource):
|
|
71
|
+
"""对某个用户的访问权限管理。"""
|
|
72
|
+
|
|
73
|
+
def request(self, user_id: str, requester_name: Optional[str] = None) -> dict:
|
|
74
|
+
"""``POST /users/{user_id}/permissions`` —— 请求访问权限。"""
|
|
75
|
+
body = {"requester_name": requester_name} if requester_name else {}
|
|
76
|
+
return self._client._request("POST", f"/users/{user_id}/permissions", json=body)
|
|
77
|
+
|
|
78
|
+
def list_requests(self, user_id: str) -> dict:
|
|
79
|
+
"""``GET /users/{user_id}/permissions/requests`` —— owner 查看待处理请求。"""
|
|
80
|
+
return self._client._request("GET", f"/users/{user_id}/permissions/requests")
|
|
81
|
+
|
|
82
|
+
def accept(self, user_id: str, request_id: str) -> dict:
|
|
83
|
+
"""``PATCH …/permissions/requests/{request_id}`` —— owner 接受请求。"""
|
|
84
|
+
return self._client._request(
|
|
85
|
+
"PATCH",
|
|
86
|
+
f"/users/{user_id}/permissions/requests/{request_id}",
|
|
87
|
+
json={"action": "accept"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def reject(self, user_id: str, request_id: str) -> dict:
|
|
91
|
+
"""``PATCH …/permissions/requests/{request_id}`` —— owner 拒绝请求。"""
|
|
92
|
+
return self._client._request(
|
|
93
|
+
"PATCH",
|
|
94
|
+
f"/users/{user_id}/permissions/requests/{request_id}",
|
|
95
|
+
json={"action": "reject"},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def revoke(self, user_id: str, token_id: str) -> dict:
|
|
99
|
+
"""``DELETE /users/{user_id}/permissions/{token_id}`` —— owner 撤销某个 token 的权限。"""
|
|
100
|
+
return self._client._request("DELETE", f"/users/{user_id}/permissions/{token_id}")
|
|
101
|
+
|
|
102
|
+
def revoke_self(self, user_id: str) -> dict:
|
|
103
|
+
"""``DELETE /users/{user_id}/permissions/self`` —— 当前 token 主动放弃对该用户的权限。"""
|
|
104
|
+
return self._client._request("DELETE", f"/users/{user_id}/permissions/self")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SongsResource(_BaseResource):
|
|
108
|
+
"""歌曲搜索 + 单曲信息。"""
|
|
109
|
+
|
|
110
|
+
def search(
|
|
111
|
+
self,
|
|
112
|
+
q: str,
|
|
113
|
+
ver: str = "jp",
|
|
114
|
+
max_results: int = 6,
|
|
115
|
+
user_id: Optional[str] = None,
|
|
116
|
+
) -> dict:
|
|
117
|
+
"""``GET /songs/search``"""
|
|
118
|
+
params = {"q": q, "ver": ver, "max_results": max_results}
|
|
119
|
+
if user_id:
|
|
120
|
+
params["user_id"] = user_id
|
|
121
|
+
return self._client._request("GET", "/songs/search", params=params)
|
|
122
|
+
|
|
123
|
+
def info(self, song_id: str) -> dict:
|
|
124
|
+
"""``GET /songs/{song_id}/image`` —— 返回歌曲信息图片(PNG bytes)。"""
|
|
125
|
+
return self._client._request("GET", f"/songs/{song_id}/image", binary=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TasksResource(_BaseResource):
|
|
129
|
+
def get(self, task_id: str) -> dict:
|
|
130
|
+
"""``GET /tasks/{task_id}``"""
|
|
131
|
+
return self._client._request("GET", f"/tasks/{task_id}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class VersionsResource(_BaseResource):
|
|
135
|
+
def list(self) -> dict:
|
|
136
|
+
"""``GET /versions``"""
|
|
137
|
+
return self._client._request("GET", "/versions")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class DxdataResource(_BaseResource):
|
|
141
|
+
def get(self) -> dict:
|
|
142
|
+
"""``GET /dxdata``"""
|
|
143
|
+
return self._client._request("GET", "/dxdata")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ImagesResource(_BaseResource):
|
|
147
|
+
"""图片生成系列(返回 PNG bytes)。"""
|
|
148
|
+
|
|
149
|
+
def user_song(self, user_id: str, song_id: str) -> bytes:
|
|
150
|
+
"""``GET /users/{user_id}/songs/{song_id}/image``"""
|
|
151
|
+
return self._client._request(
|
|
152
|
+
"GET", f"/users/{user_id}/songs/{song_id}/image", binary=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def records(self, user_id: str, command: str = "b50") -> bytes:
|
|
156
|
+
"""``GET /users/{user_id}/image?command=…`` —— b50/rct50/apb50/… 等成绩图。"""
|
|
157
|
+
return self._client._request(
|
|
158
|
+
"GET", f"/users/{user_id}/image",
|
|
159
|
+
params={"command": command}, binary=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def plate(self, user_id: str, title: str, filter_mode: Optional[str] = None) -> bytes:
|
|
163
|
+
"""``GET /users/{user_id}/plate?title=…&filter=…``"""
|
|
164
|
+
params: dict = {"title": title}
|
|
165
|
+
if filter_mode:
|
|
166
|
+
params["filter"] = filter_mode
|
|
167
|
+
return self._client._request(
|
|
168
|
+
"GET", f"/users/{user_id}/plate", params=params, binary=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def achievement(
|
|
172
|
+
self,
|
|
173
|
+
user_id: str,
|
|
174
|
+
level: str,
|
|
175
|
+
rank: Optional[str] = None,
|
|
176
|
+
filter_mode: Optional[str] = None,
|
|
177
|
+
) -> bytes:
|
|
178
|
+
"""``GET /users/{user_id}/achievement?level=…&rank=…&filter=…``"""
|
|
179
|
+
params: dict = {"level": level}
|
|
180
|
+
if rank:
|
|
181
|
+
params["rank"] = rank
|
|
182
|
+
if filter_mode:
|
|
183
|
+
params["filter"] = filter_mode
|
|
184
|
+
return self._client._request(
|
|
185
|
+
"GET", f"/users/{user_id}/achievement", params=params, binary=True,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ExportsResource(_BaseResource):
|
|
190
|
+
"""成绩导出(JSON / XML)。"""
|
|
191
|
+
|
|
192
|
+
def download(self, user_id: str, fmt: str = "json") -> Tuple[bytes, Optional[str]]:
|
|
193
|
+
"""``GET /users/{user_id}/export?fmt=json|xml``
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
(content_bytes, suggested_filename) —— filename 取自服务端
|
|
197
|
+
Content-Disposition;可能为 None。
|
|
198
|
+
"""
|
|
199
|
+
return self._client._request_with_filename(
|
|
200
|
+
"GET", f"/users/{user_id}/export", params={"fmt": fmt},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def save(self, user_id: str, fmt: str = "json", path: Optional[str] = None) -> str:
|
|
204
|
+
"""便捷方法:直接保存到本地。返回最终写入的文件路径。
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
user_id: 用户 ID
|
|
208
|
+
fmt: ``json`` 或 ``xml``
|
|
209
|
+
path: 完整文件路径;不传则用服务端推荐的文件名写到当前目录
|
|
210
|
+
"""
|
|
211
|
+
content, suggested = self.download(user_id, fmt=fmt)
|
|
212
|
+
target = path or suggested or f"jietng-{user_id}.{fmt}"
|
|
213
|
+
with open(target, "wb") as f:
|
|
214
|
+
f.write(content)
|
|
215
|
+
return target
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ============================================================
|
|
219
|
+
# 主 client
|
|
220
|
+
# ============================================================
|
|
221
|
+
|
|
222
|
+
class jietngClient:
|
|
223
|
+
"""同步 JiETNG API 客户端。
|
|
224
|
+
|
|
225
|
+
Example::
|
|
226
|
+
|
|
227
|
+
from jietng import jietngClient
|
|
228
|
+
|
|
229
|
+
client = jietngClient(token="your_token")
|
|
230
|
+
users = client.users.list()
|
|
231
|
+
b50_png = client.images.records("U123", command="b50")
|
|
232
|
+
with open("b50.png", "wb") as f:
|
|
233
|
+
f.write(b50_png)
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
token: str,
|
|
239
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
240
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
241
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
242
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
243
|
+
):
|
|
244
|
+
if not token:
|
|
245
|
+
raise ValueError("token is required")
|
|
246
|
+
self._token = token
|
|
247
|
+
self._base_url = base_url.rstrip("/")
|
|
248
|
+
self._headers = _build_headers(token, extra_headers)
|
|
249
|
+
self._http = httpx.Client(
|
|
250
|
+
base_url=self._base_url,
|
|
251
|
+
headers=self._headers,
|
|
252
|
+
timeout=timeout,
|
|
253
|
+
transport=transport,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Resource 命名空间
|
|
257
|
+
self.users = UsersResource(self)
|
|
258
|
+
self.permissions = PermissionsResource(self)
|
|
259
|
+
self.songs = SongsResource(self)
|
|
260
|
+
self.tasks = TasksResource(self)
|
|
261
|
+
self.versions = VersionsResource(self)
|
|
262
|
+
self.dxdata = DxdataResource(self)
|
|
263
|
+
self.images = ImagesResource(self)
|
|
264
|
+
self.exports = ExportsResource(self)
|
|
265
|
+
|
|
266
|
+
# ---- context manager ----
|
|
267
|
+
|
|
268
|
+
def __enter__(self) -> "jietngClient":
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, *exc: Any) -> None:
|
|
272
|
+
self.close()
|
|
273
|
+
|
|
274
|
+
def close(self) -> None:
|
|
275
|
+
self._http.close()
|
|
276
|
+
|
|
277
|
+
# ---- 内部请求方法 ----
|
|
278
|
+
|
|
279
|
+
def _request(self, method: str, path: str, *, binary: bool = False, **kwargs: Any) -> Any:
|
|
280
|
+
resp = self._http.request(method, path, **kwargs)
|
|
281
|
+
if binary:
|
|
282
|
+
return _binary_response(resp)
|
|
283
|
+
return _check_response(resp)
|
|
284
|
+
|
|
285
|
+
def _request_with_filename(
|
|
286
|
+
self, method: str, path: str, **kwargs: Any,
|
|
287
|
+
) -> Tuple[bytes, Optional[str]]:
|
|
288
|
+
resp = self._http.request(method, path, **kwargs)
|
|
289
|
+
content = _binary_response(resp)
|
|
290
|
+
return content, _attachment_filename(resp)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""异常类型 / Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
All API failures raise a subclass of :class:`jietngError`. Use specific
|
|
4
|
+
subclasses (``NotFoundError``, ``RateLimitedError`` …) to handle expected
|
|
5
|
+
failure modes; fall back to ``APIError`` for the generic case.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class jietngError(Exception):
|
|
13
|
+
"""SDK 基类异常。所有错误最终都是它或其子类。"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(jietngError):
|
|
17
|
+
"""HTTP API 返回了非 2xx。
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
status_code: HTTP 状态码
|
|
21
|
+
error: 服务器返回的 ``error`` 字段(若有)
|
|
22
|
+
message: 服务器返回的 ``message`` 字段(若有)
|
|
23
|
+
payload: 完整的响应 JSON(若服务器返回的是 JSON),方便排查
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
status_code: int,
|
|
29
|
+
*,
|
|
30
|
+
error: Optional[str] = None,
|
|
31
|
+
message: Optional[str] = None,
|
|
32
|
+
payload: Any = None,
|
|
33
|
+
):
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.error = error
|
|
36
|
+
self.message = message
|
|
37
|
+
self.payload = payload
|
|
38
|
+
head = f"[{status_code}]"
|
|
39
|
+
if error:
|
|
40
|
+
head += f" {error}"
|
|
41
|
+
if message:
|
|
42
|
+
head += f": {message}"
|
|
43
|
+
super().__init__(head)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthenticationError(APIError):
|
|
47
|
+
"""401 - token 缺失 / 无效 / 已撤销。"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PermissionDeniedError(APIError):
|
|
51
|
+
"""403 - 当前 token 对该用户没有访问权限。"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(APIError):
|
|
55
|
+
"""404 - 用户 / 任务 / 资源不存在。"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ValidationError(APIError):
|
|
59
|
+
"""400 - 参数缺失或不合法。"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitedError(APIError):
|
|
63
|
+
"""429 - 频率限制触发。"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ServerError(APIError):
|
|
67
|
+
"""500 - 服务器内部错误。"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class QueueFullError(APIError):
|
|
71
|
+
"""503 - 服务端任务队列已满,请稍后再试。"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# status_code → 异常类
|
|
75
|
+
_STATUS_MAP = {
|
|
76
|
+
400: ValidationError,
|
|
77
|
+
401: AuthenticationError,
|
|
78
|
+
403: PermissionDeniedError,
|
|
79
|
+
404: NotFoundError,
|
|
80
|
+
429: RateLimitedError,
|
|
81
|
+
500: ServerError,
|
|
82
|
+
503: QueueFullError,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def from_response(status_code: int, payload: Any) -> APIError:
|
|
87
|
+
"""根据 HTTP 状态码 + payload 构造对应异常实例。"""
|
|
88
|
+
cls = _STATUS_MAP.get(status_code, APIError)
|
|
89
|
+
error = None
|
|
90
|
+
message = None
|
|
91
|
+
if isinstance(payload, dict):
|
|
92
|
+
error = payload.get("error")
|
|
93
|
+
message = payload.get("message")
|
|
94
|
+
return cls(status_code, error=error, message=message, payload=payload)
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jietng
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the JiETNG maimai DX (舞萌DX) score management API.
|
|
5
|
+
Author-email: Matsuki <matsuk1@proton.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://jietng.matsuk1.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Matsuk1/JiETNG
|
|
9
|
+
Project-URL: Documentation, https://jietng.matsuk1.com/developer-api
|
|
10
|
+
Project-URL: Issues, https://github.com/Matsuk1/JiETNG/issues
|
|
11
|
+
Keywords: maimai,maimai-dx,舞萌DX,舞萌查分器,查分器,rating-calculator,best-50,b50,score-tracker,line-bot,jietng
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Games/Entertainment
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: httpx>=0.25
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# jietng — Python SDK
|
|
33
|
+
|
|
34
|
+
[`pip install jietng`](https://pypi.org/project/jietng/) — Python 客户端,封装 [JiETNG](https://jietng.matsuk1.com) 舞萌DX 查分器的 HTTP API。
|
|
35
|
+
|
|
36
|
+
支持同步 / 异步两套客户端、覆盖全部 v2 端点(用户 / 权限 / 同步 / 搜歌 / 成绩图 / 导出 …)、类型注解齐全。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install jietng
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
需要 Python ≥ 3.8、httpx ≥ 0.25。
|
|
45
|
+
|
|
46
|
+
## 获取 Token
|
|
47
|
+
|
|
48
|
+
JiETNG API 通过 Bearer Token 鉴权。申请方式见 <https://jietng.matsuk1.com/developer-api> —— 发邮件到 `matsuk1@proton.me` 索取。
|
|
49
|
+
|
|
50
|
+
## 快速开始
|
|
51
|
+
|
|
52
|
+
### 同步
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from jietng import jietngClient
|
|
56
|
+
|
|
57
|
+
with jietngClient(token="your_token") as client:
|
|
58
|
+
users = client.users.list()
|
|
59
|
+
print(users["count"], "registered users")
|
|
60
|
+
|
|
61
|
+
# 取 B50 成绩图(返回 PNG bytes)
|
|
62
|
+
png = client.images.records("U1234567890", command="b50")
|
|
63
|
+
with open("b50.png", "wb") as f:
|
|
64
|
+
f.write(png)
|
|
65
|
+
|
|
66
|
+
# 触发后台同步
|
|
67
|
+
task = client.users.trigger_sync("U1234567890")
|
|
68
|
+
print("queued:", task["task_id"])
|
|
69
|
+
|
|
70
|
+
# 导出成绩为 JSON / XML,文件名由服务端推荐(含玩家名 + 时间戳)
|
|
71
|
+
path = client.exports.save("U1234567890", fmt="json")
|
|
72
|
+
print("exported to", path)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 异步
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from jietng import AsyncjietngClient
|
|
80
|
+
|
|
81
|
+
async def main():
|
|
82
|
+
async with AsyncjietngClient(token="your_token") as client:
|
|
83
|
+
print(await client.songs.search("PANDORA", ver="jp", max_results=3))
|
|
84
|
+
png = await client.images.plate("U1234567890", title="真神")
|
|
85
|
+
with open("plate.png", "wb") as f:
|
|
86
|
+
f.write(png)
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 资源总览
|
|
92
|
+
|
|
93
|
+
| 命名空间 | 主要方法 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `client.users` | `list / get / create / delete / trigger_sync / bind / update_bind / get_rebind_url / get_settings_url` |
|
|
96
|
+
| `client.permissions` | `request / list_requests / accept / reject / revoke / revoke_self` |
|
|
97
|
+
| `client.songs` | `search / info` |
|
|
98
|
+
| `client.tasks` | `get` |
|
|
99
|
+
| `client.versions` | `list` |
|
|
100
|
+
| `client.dxdata` | `get` |
|
|
101
|
+
| `client.images` | `user_song / records / plate / achievement` |
|
|
102
|
+
| `client.exports` | `download / save` |
|
|
103
|
+
|
|
104
|
+
所有方法的形参 / 返回结构与 [JiETNG API 文档](https://jietng.matsuk1.com/developer-api) 一一对应。
|
|
105
|
+
|
|
106
|
+
## 错误处理
|
|
107
|
+
|
|
108
|
+
所有 HTTP 非 2xx 状态都会抛 `APIError` 子类。可以按需 catch 具体类型:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from jietng import jietngClient, NotFoundError, PermissionDeniedError, RateLimitedError, QueueFullError
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
png = client.images.records("U_unknown", command="b50")
|
|
115
|
+
except NotFoundError:
|
|
116
|
+
print("user has no records yet")
|
|
117
|
+
except PermissionDeniedError:
|
|
118
|
+
print("your token doesn't have access to this user")
|
|
119
|
+
except RateLimitedError:
|
|
120
|
+
print("slow down")
|
|
121
|
+
except QueueFullError:
|
|
122
|
+
print("server task queue is full, retry later")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
完整异常层级:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
jietngError # 基类
|
|
129
|
+
└─ APIError # 任意 HTTP 非 2xx
|
|
130
|
+
├─ ValidationError # 400
|
|
131
|
+
├─ AuthenticationError # 401
|
|
132
|
+
├─ PermissionDeniedError # 403
|
|
133
|
+
├─ NotFoundError # 404
|
|
134
|
+
├─ RateLimitedError # 429
|
|
135
|
+
├─ ServerError # 500
|
|
136
|
+
└─ QueueFullError # 503
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
每个异常实例都带 `status_code` / `error` / `message` / `payload` 字段,方便排查。
|
|
140
|
+
|
|
141
|
+
## 自定义
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
client = jietngClient(
|
|
145
|
+
token="your_token",
|
|
146
|
+
base_url="https://your-self-hosted.example.com/api/v2", # 自托管时
|
|
147
|
+
timeout=60.0,
|
|
148
|
+
extra_headers={"X-App-Name": "MyBot"},
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 许可
|
|
153
|
+
|
|
154
|
+
MIT。代码:<https://github.com/Matsuk1/JiETNG/tree/main/client>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/jietng/__init__.py
|
|
5
|
+
src/jietng/_http.py
|
|
6
|
+
src/jietng/async_client.py
|
|
7
|
+
src/jietng/client.py
|
|
8
|
+
src/jietng/exceptions.py
|
|
9
|
+
src/jietng/py.typed
|
|
10
|
+
src/jietng.egg-info/PKG-INFO
|
|
11
|
+
src/jietng.egg-info/SOURCES.txt
|
|
12
|
+
src/jietng.egg-info/dependency_links.txt
|
|
13
|
+
src/jietng.egg-info/requires.txt
|
|
14
|
+
src/jietng.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jietng
|