qqmusic-api-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import hashlib
2
+ import json
3
+ import random
4
+ import time
5
+ from pathlib import Path
6
+
7
+ # 定义全局变量
8
+ UUID_CHARS = "0123456789ABCDEF"
9
+ CACHE_DIR = Path(__file__).resolve().parent.parent.parent / ".cache"
10
+ DATA_DIR = Path(__file__).resolve().parent.parent / "data"
11
+ API_DIR = DATA_DIR / "api"
12
+
13
+ # 确保缓存目录存在
14
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+
17
+ def get_cache_file(*args) -> str:
18
+ return str(CACHE_DIR.joinpath(*args))
19
+
20
+
21
+ def get_api(field: str) -> dict:
22
+ path = API_DIR / f"{field.lower()}.json"
23
+ if path.exists():
24
+ with path.open() as f:
25
+ data = json.load(f)
26
+ return data
27
+ else:
28
+ return {}
29
+
30
+
31
+ def random_string(length: int, chars: str = UUID_CHARS) -> str:
32
+ return "".join(random.choices(chars, k=length))
33
+
34
+
35
+ def calc_md5(*multi_string) -> str:
36
+ md5 = hashlib.md5()
37
+ for s in multi_string:
38
+ md5.update(s if isinstance(s, bytes) else s.encode())
39
+ return md5.hexdigest()
40
+
41
+
42
+ def hash33(s: str, h: int = 0) -> int:
43
+ for c in s:
44
+ h = (h << 5) + h + ord(c)
45
+ return 2147483647 & h
46
+
47
+
48
+ def random_uuid() -> str:
49
+ uuid_format = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
50
+ return "".join(random.choice(UUID_CHARS) if c in "xy" else c for c in uuid_format)
51
+
52
+
53
+ def random_searchID() -> str:
54
+ e = random.randint(1, 20)
55
+ t = e * 18014398509481984
56
+ n = random.randint(0, 4194304) * 4294967296
57
+ a = time.time()
58
+ r = round(a * 1000) % (24 * 60 * 60 * 1000)
59
+ return str(t + n + r)
60
+
61
+
62
+ def parse_song_info(song_info: dict) -> dict:
63
+ # 解析专辑信息
64
+ album = {
65
+ "id": song_info["album"]["id"],
66
+ "mid": song_info["album"]["mid"],
67
+ "name": song_info["album"]["name"],
68
+ "time_public": song_info["album"].get("time_public", ""),
69
+ }
70
+ # 解析MV信息
71
+ mv = {
72
+ "id": song_info["mv"]["id"],
73
+ "name": song_info["mv"].get("name", ""),
74
+ "vid": song_info["mv"]["vid"],
75
+ }
76
+ # 解析歌手信息
77
+ singer = [
78
+ {
79
+ "id": s["id"],
80
+ "mid": s["mid"],
81
+ "name": s["name"],
82
+ "type": s.get("type"),
83
+ "uin": s.get("uin"),
84
+ }
85
+ for s in song_info["singer"]
86
+ ]
87
+ # 解析歌曲信息
88
+ info = {
89
+ "id": song_info["id"],
90
+ "mid": song_info["mid"],
91
+ "name": song_info["name"],
92
+ "title": song_info["title"],
93
+ "subTitle": song_info.get("subtitle", ""),
94
+ "language": song_info["language"],
95
+ "time_public": song_info.get("time_public", ""),
96
+ "tag": song_info.get("tag", ""),
97
+ "type": song_info["type"],
98
+ "album": album,
99
+ "mv": mv,
100
+ "singer": singer,
101
+ }
102
+ # 解析文件信息
103
+ file = {
104
+ "media_mid": song_info["file"]["media_mid"],
105
+ "new_0": song_info["file"]["size_new"][0],
106
+ "new_1": song_info["file"]["size_new"][1],
107
+ "new_2": song_info["file"]["size_new"][2],
108
+ "flac": song_info["file"]["size_flac"],
109
+ "ogg_192": song_info["file"]["size_192ogg"],
110
+ "ogg_96": song_info["file"]["size_96ogg"],
111
+ "mp3_320": song_info["file"]["size_320mp3"],
112
+ "mp3_128": song_info["file"]["size_128mp3"],
113
+ "aac_192": song_info["file"]["size_192aac"],
114
+ "aac_96": song_info["file"]["size_96aac"],
115
+ "aac_48": song_info["file"]["size_48aac"],
116
+ }
117
+ # 组装结果
118
+ result = {
119
+ "info": info,
120
+ "file": file,
121
+ "lyric": {
122
+ "match": song_info.get("lyric", ""),
123
+ "content": song_info.get("content", ""),
124
+ },
125
+ "pay": song_info.get("pay", {}),
126
+ "grp": [parse_song_info(song) for song in song_info.get("grp", [])],
127
+ "vs": song_info.get("vs", []),
128
+ }
129
+ return result
130
+
131
+
132
+ def filter_data(data: dict) -> dict:
133
+ keys = [""]
134
+ for key in keys:
135
+ data.pop(key, "")
136
+ return data
137
+
138
+
139
+ def singer_to_str(data: dict) -> str:
140
+ return "&".join([singer["name"] for singer in data["singer"]])
@@ -0,0 +1,92 @@
1
+ from ..exceptions import (
2
+ CredentialNoMusicidException,
3
+ CredentialNoMusickeyException,
4
+ CredientialCanNotRefreshException,
5
+ )
6
+
7
+
8
+ class Credential:
9
+ def __init__(
10
+ self, musicid: str = "", musickey: str = "", refresh_key: str = "", **kwagrs
11
+ ):
12
+ self.musicid = musicid
13
+ self.musickey = musickey
14
+ self.refresh_key = refresh_key
15
+ self.login_type = 1 if "W_X" in musickey else 2
16
+
17
+ for key, value in kwagrs.items():
18
+ setattr(self, key, value)
19
+
20
+ def get_dict(self) -> dict:
21
+ cookies = {}
22
+ for key, value in self.__dict__.items():
23
+ if key not in cookies and value is not None:
24
+ cookies[key] = value
25
+ return cookies
26
+
27
+ def has_musicid(self) -> bool:
28
+ """
29
+ 是否提供 musicid
30
+ """
31
+ return bool(self.musicid)
32
+
33
+ def has_musickey(self) -> bool:
34
+ """
35
+ 是否提供 musickey
36
+ """
37
+ return bool(self.musickey)
38
+
39
+ def can_refresh(self) -> bool:
40
+ """
41
+ 是否能刷新 Credential
42
+ """
43
+ return bool(self.refresh_key)
44
+
45
+ def raise_for_cannot_refresh(self):
46
+ """
47
+ 无法刷新 Credential 时则抛出异常
48
+ """
49
+ if not self.can_refresh():
50
+ raise CredientialCanNotRefreshException()
51
+
52
+ def raise_for_no_musicid(self):
53
+ """
54
+ 没有提供 musicid 时抛出异常
55
+ """
56
+ if not self.has_musicid():
57
+ raise CredentialNoMusicidException()
58
+
59
+ def raise_for_no_musickey(self):
60
+ """
61
+ 没有提供 musickey 时抛出异常
62
+ """
63
+ if not self.has_musickey():
64
+ raise CredentialNoMusickeyException()
65
+
66
+ async def refresh(self):
67
+ """
68
+ 刷新 cookies
69
+ """
70
+ from ..api.login import refresh_cookies
71
+
72
+ c = await refresh_cookies(self)
73
+ self.musicid = c.musicid
74
+ self.musickey = c.musickey
75
+ self.refresh_key = c.refresh_key
76
+
77
+ @classmethod
78
+ def from_cookies(cls, cookies: dict = {}) -> "Credential":
79
+ """
80
+ 从 cookies 新建 Credential
81
+
82
+ Args:
83
+ cookies : Cookies.
84
+
85
+ Returns:
86
+ Credential: 凭据类
87
+ """
88
+ c = cls()
89
+ c.musicid = cookies["musicid"]
90
+ c.musickey = cookies["musickey"]
91
+ c.refresh_key = cookies["refresh_key"]
92
+ return c
@@ -0,0 +1,249 @@
1
+ import asyncio
2
+ import atexit
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .. import settings
10
+ from ..exceptions import ClientException, NetworkException
11
+ from .credential import Credential
12
+
13
+ HEADERS = {
14
+ "User-Agent": "Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54",
15
+ "Referer": "https://y.qq.com",
16
+ }
17
+
18
+ __session_pool: dict[asyncio.AbstractEventLoop, aiohttp.ClientSession] = {}
19
+
20
+
21
+ def get_aiohttp_session() -> aiohttp.ClientSession:
22
+ """
23
+ 获取当前模块的 aiohttp.ClientSession 对象,用于自定义请求
24
+
25
+ Returns:
26
+ aiohttp.ClientSession
27
+ """
28
+ loop = asyncio.get_event_loop()
29
+ session = __session_pool.get(loop, None)
30
+ if session is None:
31
+ session = aiohttp.ClientSession(
32
+ loop=loop, connector=aiohttp.TCPConnector(), trust_env=True
33
+ )
34
+ __session_pool[loop] = session
35
+
36
+ return session
37
+
38
+
39
+ @dataclass
40
+ class Api:
41
+ """
42
+ 用于请求的 Api 类
43
+
44
+ Args:
45
+ url: 请求地址. Defaults to API_URL
46
+ method: 请求方法
47
+ module: 请求模块. Defaults to ""
48
+ comment: 注释. Defaults to ""
49
+ verify: 是否验证凭据. Defaults to False
50
+ json_body: 是否使用 json 作为载荷. Defaults to False
51
+ data: 请求载荷. Defaults to {}
52
+ params: 请求参数. Defaults to {}
53
+ headers: 请求头. Defaults to {}
54
+ credential: 凭据. Defaults to Credential()
55
+ extra_common: 额外参数. Defaults to {}
56
+ """
57
+
58
+ method: str
59
+ module: str = ""
60
+ url: str = settings.API_URL
61
+ comment: str = ""
62
+ verify: bool = False
63
+ json_body: bool = False
64
+ data: dict = field(default_factory=dict)
65
+ params: dict = field(default_factory=dict)
66
+ headers: dict = field(default_factory=dict)
67
+ credential: Credential = field(default_factory=Credential)
68
+ extra_common: dict = field(default_factory=dict)
69
+
70
+ def __post_init__(self) -> None:
71
+ if not self.module:
72
+ self.method = self.method.upper()
73
+ else:
74
+ self.json_body = True
75
+ self.original_data = self.data.copy()
76
+ self.original_params = self.params.copy()
77
+ self.data = {k: "" for k in self.data.keys()}
78
+ self.params = {k: "" for k in self.params.keys()}
79
+ self.headers = {k: "" for k in self.headers.keys()}
80
+ self.extra_common = {k: "" for k in self.extra_common.keys()}
81
+ self.__result: str | dict | None = None
82
+
83
+ def __setattr__(self, __name: str, __value: Any) -> None:
84
+ """
85
+ 每次更新参数都要把 __result 清除
86
+ """
87
+ if self.initialized and __name != "_Api__result":
88
+ self.__result = None
89
+ return super().__setattr__(__name, __value)
90
+
91
+ @property
92
+ def initialized(self):
93
+ return "_Api__result" in self.__dict__
94
+
95
+ @property
96
+ async def result(self) -> dict | None:
97
+ """
98
+ 获取请求结果
99
+ """
100
+ if self.__result is None:
101
+ self.__result = await self.request()
102
+ return self.__result
103
+
104
+ def update_params(self, **kwargs) -> "Api":
105
+ """
106
+ 毫无亮点的更新 params
107
+ """
108
+ self.params = kwargs
109
+ self.__result = None
110
+ return self
111
+
112
+ def update_data(self, **kwargs) -> "Api":
113
+ """
114
+ 毫无亮点的更新 data
115
+ """
116
+ self.data = kwargs
117
+ self.__result = None
118
+ return self
119
+
120
+ def update_headers(self, **kwargs) -> "Api":
121
+ """
122
+ 毫无亮点的更新 headers
123
+ """
124
+ self.headers = kwargs
125
+ self.__result = None
126
+ return self
127
+
128
+ def update_extra_common(self, **kwargs) -> "Api":
129
+ """
130
+ 毫无亮点的更新 extra_common
131
+ """
132
+ self.extra_common = kwargs
133
+ self.__result = None
134
+ return self
135
+
136
+ def __prepare_params_data(self) -> None:
137
+ """
138
+ 准备请求参数
139
+ """
140
+ new_params, new_data = {}, {}
141
+ for key, value in self.params.items():
142
+ if isinstance(value, bool):
143
+ new_params[key] = int(value)
144
+ elif value is not None:
145
+ new_params[key] = value
146
+ for key, value in self.data.items():
147
+ if isinstance(value, bool):
148
+ new_params[key] = int(value)
149
+ elif value is not None:
150
+ new_data[key] = value
151
+ self.params, self.data = new_params, new_data
152
+
153
+ def __prepare_api_data(self) -> None:
154
+ """
155
+ 准备API请求数据
156
+ """
157
+ common = {
158
+ "ct": "11",
159
+ "cv": settings.QQMUSIC_VERSION_CODE,
160
+ "v": settings.QQMUSIC_VERSION_CODE,
161
+ "tmeAppID": "qqmusic",
162
+ "QIMEI36": settings.QIMEI36,
163
+ "uid": settings.UID,
164
+ "format": "json",
165
+ "inCharset": "utf-8",
166
+ "outCharset": "utf-8",
167
+ }
168
+
169
+ if self.verify:
170
+ self.credential.raise_for_no_musickey()
171
+ self.credential.raise_for_no_musicid()
172
+
173
+ common["qq"] = self.credential.musicid
174
+ common["authst"] = self.credential.musickey
175
+ common["tmeLoginType"] = str(self.credential.login_type)
176
+
177
+ common.update(self.extra_common)
178
+
179
+ data = {
180
+ "comm": common,
181
+ "request": {
182
+ "module": self.module,
183
+ "method": self.method,
184
+ "param": self.params,
185
+ },
186
+ }
187
+ self.data = data
188
+
189
+ def __prepare_request(self) -> dict:
190
+ """
191
+ 准备请求配置参数
192
+ """
193
+ config = {
194
+ "url": self.url,
195
+ "method": self.method,
196
+ "data": self.data,
197
+ "params": self.params,
198
+ "headers": HEADERS.copy() if len(self.headers) == 0 else self.headers,
199
+ }
200
+ if self.json_body:
201
+ config["headers"]["Content-Type"] = "application/json"
202
+ config["data"] = json.dumps(config["data"], ensure_ascii=False).encode()
203
+ if self.module:
204
+ config["method"] = "POST"
205
+ config["params"] = ""
206
+ return config
207
+
208
+ async def request(self) -> dict | None:
209
+ """
210
+ 向接口发送请求
211
+ """
212
+ if self.module:
213
+ self.__prepare_api_data()
214
+ self.__prepare_params_data()
215
+ config = self.__prepare_request()
216
+ session = get_aiohttp_session()
217
+ try:
218
+ async with session.request(**config) as resp:
219
+ try:
220
+ resp.raise_for_status()
221
+ except aiohttp.ClientResponseError as e:
222
+ raise NetworkException(e.status, e.message)
223
+ return self.__process_response(resp, await resp.text())
224
+ except aiohttp.client_exceptions.ClientConnectorError:
225
+ raise ClientException()
226
+
227
+ def __process_response(
228
+ self, resp: aiohttp.ClientResponse, resp_text: str
229
+ ) -> dict | None:
230
+ content_length = resp.headers.get("content-length")
231
+ if content_length and int(content_length) == 0:
232
+ return None
233
+ try:
234
+ resp_data = json.loads(resp_text)
235
+ if self.module:
236
+ request_data = resp_data["request"]
237
+ return request_data["data"]
238
+ return resp_data
239
+ except Exception:
240
+ return None
241
+
242
+
243
+ @atexit.register
244
+ def __clean() -> None:
245
+ """
246
+ 程序退出清理操作。
247
+ """
248
+ for session in __session_pool.values():
249
+ asyncio.run(session.close())