qqmusic-api-python 0.7.0__py3-none-any.whl → 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qqmusic_api/__init__.py CHANGED
@@ -22,7 +22,7 @@ from .core.exceptions import (
22
22
  from .core.versioning import Platform
23
23
  from .models.request import Credential
24
24
 
25
- __version__ = "0.7.0"
25
+ __version__ = "0.7.2"
26
26
 
27
27
  __all__ = [
28
28
  "ApiDataError",
@@ -19,7 +19,7 @@ from .exceptions import (
19
19
  NetworkError,
20
20
  RatelimitedError,
21
21
  )
22
- from .request import Request
22
+ from .request import BaseRequest, CgiRequest, HttpRequest, ItemPaginatedCgiRequest, PaginatedCgiRequest
23
23
  from .versioning import DEFAULT_VERSION_POLICY, Platform, VersionPolicy, VersionProfile
24
24
 
25
25
  __all__ = [
@@ -27,22 +27,26 @@ __all__ = [
27
27
  "ApiDataError",
28
28
  "ApiException",
29
29
  "BaseApiException",
30
+ "BaseRequest",
30
31
  "CgiApiException",
32
+ "CgiRequest",
31
33
  "Client",
32
34
  "CredentialExpiredError",
33
35
  "CredentialInvalidError",
34
36
  "CredentialRefreshError",
35
37
  "GlobalApiError",
36
38
  "HTTPError",
39
+ "HttpRequest",
40
+ "ItemPaginatedCgiRequest",
37
41
  "LoginAccountRestrictedError",
38
42
  "LoginAuthExpiredError",
39
43
  "LoginDeviceLimitError",
40
44
  "LoginError",
41
45
  "LoginRateLimitError",
42
46
  "NetworkError",
47
+ "PaginatedCgiRequest",
43
48
  "Platform",
44
49
  "RatelimitedError",
45
- "Request",
46
50
  "VersionPolicy",
47
51
  "VersionProfile",
48
52
  ]
@@ -0,0 +1,227 @@
1
+ """API Context 管理类. 维护全局请求环境、会话与参数构造逻辑."""
2
+
3
+ import time
4
+ from collections.abc import Sequence
5
+ from typing import Any, cast
6
+
7
+ import anyio
8
+ import orjson as json
9
+ from niquests import AsyncSession
10
+ from niquests.exceptions import RequestException
11
+
12
+ from ..algorithms import zzc_sign
13
+ from ..models.request import Credential
14
+ from ..utils.device import Device, DeviceManager
15
+ from ..utils.qimei import QimeiManager
16
+ from .exceptions import HTTPError, NetworkError
17
+ from .versioning import DEFAULT_VERSION_POLICY, Platform, VersionPolicy
18
+
19
+
20
+ class ApiContext:
21
+ """全局请求上下文与会话管理."""
22
+
23
+ def __init__(
24
+ self,
25
+ credential: Credential | None = None,
26
+ *,
27
+ platform: Platform | None = None,
28
+ device_path: str | None = None,
29
+ version_policy: VersionPolicy = DEFAULT_VERSION_POLICY,
30
+ session: AsyncSession,
31
+ ) -> None:
32
+ """初始化 ApiContext.
33
+
34
+ Args:
35
+ credential: 全局默认凭证.
36
+ platform: 全局默认请求平台.
37
+ device_path: 设备信息文件路径.
38
+ version_policy: 版本策略规则.
39
+ session: HTTP 异步 Session.
40
+ """
41
+ self.credential = credential or Credential()
42
+ self.platform = platform or Platform.ANDROID
43
+ self.version_policy = version_policy
44
+ self._session = session
45
+ self._device_store = DeviceManager(device_path)
46
+ self._session_lock = anyio.Lock()
47
+ self._qimei_manager = QimeiManager(
48
+ device_store=self._device_store,
49
+ app_version=self.version_policy.get_qimei_app_version(),
50
+ sdk_version=self.version_policy.get_qimei_sdk_version(),
51
+ session=self._session,
52
+ )
53
+
54
+ async def get_user_agent(self, platform: Platform | None = None) -> str:
55
+ """根据指定或默认平台生成请求所需的 User-Agent.
56
+
57
+ Args:
58
+ platform: 平台标识. 若为 None, 使用当前上下文默认平台.
59
+
60
+ Returns:
61
+ 格式化好的 User-Agent 字符串.
62
+ """
63
+ target_platform = platform or self.platform
64
+ return self.version_policy.get_user_agent(target_platform, await self._device_store.get_device())
65
+
66
+ async def ensure_session(self, platform: Platform | None = None) -> None:
67
+ """校验并更新平台会话信息 (针对 Android 平台)."""
68
+ target_platform = platform or self.platform
69
+ if target_platform != Platform.ANDROID:
70
+ return
71
+
72
+ def _is_session_valid(dev: Device) -> bool:
73
+ return (
74
+ dev.session_save_time is not None
75
+ and (int(time.time()) - dev.session_save_time) < 86400
76
+ and bool(dev.session_uid and dev.session_sid)
77
+ )
78
+
79
+ device = await self._device_store.get_device()
80
+ if _is_session_valid(device):
81
+ return
82
+
83
+ async with self._session_lock:
84
+ device = await self._device_store.get_device()
85
+ if _is_session_valid(device):
86
+ return
87
+
88
+ finalcomm = self.version_policy.build_comm(
89
+ platform=Platform.ANDROID,
90
+ credential=self.credential,
91
+ device=device,
92
+ qimei=cast("dict[str, str]", await self._qimei_manager.get_cached()),
93
+ guid=device.open_udid,
94
+ )
95
+ payload: dict[str, Any] = {
96
+ "comm": finalcomm,
97
+ "req_0": {
98
+ "module": "music.getSession.session",
99
+ "method": "GetSession",
100
+ "param": {
101
+ "uid": device.session_uid or "",
102
+ "vkey": 0,
103
+ "caller": 0,
104
+ },
105
+ },
106
+ }
107
+ user_agent = await self.get_user_agent(Platform.ANDROID)
108
+ try:
109
+ resp = await self._session.post(
110
+ "https://u.y.qq.com/cgi-bin/musicu.fcg",
111
+ json=payload,
112
+ headers={"User-Agent": user_agent},
113
+ )
114
+ await self._session.gather(resp)
115
+ except RequestException as exc:
116
+ raise NetworkError(str(exc)) from exc
117
+
118
+ if resp.status_code != 200:
119
+ raise HTTPError(
120
+ f"HTTP 请求状态码异常: {resp.status_code}",
121
+ status_code=cast("int", resp.status_code),
122
+ )
123
+
124
+ resp_data = resp.json()
125
+ session_data = resp_data["req_0"]["data"]["session"]
126
+ device.session_uid = str(session_data["uid"])
127
+ device.session_sid = session_data["sid"]
128
+ device.session_vkey = session_data.get("vkey")
129
+ device.session_save_time = int(time.time())
130
+ await self._device_store.save_device()
131
+
132
+ async def prepare_http_kwargs(
133
+ self,
134
+ credential: Credential | None = None,
135
+ **kwargs: Any,
136
+ ) -> dict[str, Any]:
137
+ """准备标准 HTTP 请求所需要的 kwargs (注入 Cookies 和 User-Agent).
138
+
139
+ Args:
140
+ credential: 请求凭证, 优先于上下文默认凭证.
141
+ platform: 请求平台, 优先于上下文默认平台.
142
+ **kwargs: 额外请求参数.
143
+
144
+ Returns:
145
+ 组装好的 kwargs 字典.
146
+ """
147
+ prepared = kwargs.copy()
148
+ cred = credential or self.credential
149
+ user_cookies = prepared.pop("cookies", {})
150
+ cookies: dict[str, str] = {}
151
+ if cred.musicid:
152
+ cookies["uin"] = cred.str_musicid or str(cred.musicid)
153
+ cookies["qqmusic_uin"] = cred.str_musicid or str(cred.musicid)
154
+ if cred.musickey:
155
+ cookies["qm_keyst"] = cred.musickey
156
+ cookies["qqmusic_key"] = cred.musickey
157
+ cookies.update(user_cookies)
158
+ if cookies:
159
+ prepared["cookies"] = cookies
160
+
161
+ headers = prepared.get("headers", {})
162
+ if "User-Agent" not in headers:
163
+ headers["User-Agent"] = await self.get_user_agent(Platform.WEB)
164
+ prepared["headers"] = headers
165
+
166
+ return prepared
167
+
168
+ async def build_api_kwargs(
169
+ self,
170
+ data: Sequence[dict[str, Any]],
171
+ comm: dict[str, Any] | None = None,
172
+ credential: Credential | None = None,
173
+ platform: Platform | None = None,
174
+ *,
175
+ override_comm: bool = False,
176
+ sign: bool = False,
177
+ ) -> tuple[str, dict[str, Any], dict[str, str], dict[str, str]]:
178
+ """构建 CGI 接口调用的 URL、payload、params 与 headers.
179
+
180
+ Args:
181
+ data: 请求子项列表.
182
+ comm: 自定义公共参数.
183
+ credential: 请求凭证.
184
+ platform: 请求平台.
185
+ override_comm: 是否完全覆盖默认公共参数.
186
+ sign: 是否需要签名 URL.
187
+
188
+ Returns:
189
+ (url, payload, params, headers) 四元组.
190
+ """
191
+ target_platform = platform or self.platform
192
+ if target_platform == Platform.ANDROID:
193
+ await self.ensure_session(target_platform)
194
+
195
+ device = await self._device_store.get_device()
196
+ if override_comm:
197
+ final = (comm or {}).copy()
198
+ else:
199
+ final = self.version_policy.build_comm(
200
+ platform=target_platform,
201
+ credential=credential or self.credential,
202
+ device=device,
203
+ qimei=cast("dict[str, str]", await self._qimei_manager.get_cached())
204
+ if target_platform == Platform.ANDROID
205
+ else None,
206
+ guid=device.open_udid,
207
+ )
208
+ if comm:
209
+ final.update(comm)
210
+
211
+ user_agent = await self.get_user_agent(target_platform)
212
+
213
+ payload: dict[str, Any] = {
214
+ "comm": final,
215
+ }
216
+ params: dict[str, str] = {}
217
+ for idx, req in enumerate(data):
218
+ payload[f"req_{idx}"] = req
219
+
220
+ if sign:
221
+ params["_"] = str(int(time.time() * 1000))
222
+ params["sign"] = zzc_sign(json.dumps(payload))
223
+
224
+ url = "https://u.y.qq.com/cgi-bin/musicu.fcg" if not sign else "https://u.y.qq.com/cgi-bin/musics.fcg"
225
+ headers = {"User-Agent": user_agent}
226
+
227
+ return url, payload, params, headers