qqmusic-api-python 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/PKG-INFO +4 -3
  2. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/README.md +1 -1
  3. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/pyproject.toml +6 -1
  4. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/__init__.py +1 -1
  5. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/login.py +0 -2
  6. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/singer.py +192 -46
  7. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/song.py +0 -1
  8. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/credential.py +11 -3
  9. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/device.py +3 -2
  10. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/network.py +38 -6
  11. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/qimei.py +4 -4
  12. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/session.py +25 -3
  13. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/sign.py +3 -2
  14. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_session.py +1 -1
  15. qqmusic_api_python-0.3.1/tests/test_singer.py +62 -0
  16. qqmusic_api_python-0.3.0/tests/test_network.py +0 -51
  17. qqmusic_api_python-0.3.0/tests/test_singer.py +0 -34
  18. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/.gitignore +0 -0
  19. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/LICENSE +0 -0
  20. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/album.py +0 -0
  21. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/exceptions/__init__.py +0 -0
  22. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/exceptions/api_exception.py +0 -0
  23. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/lyric.py +0 -0
  24. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/mv.py +0 -0
  25. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/search.py +0 -0
  26. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/songlist.py +0 -0
  27. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/top.py +0 -0
  28. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/user.py +0 -0
  29. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/__init__.py +0 -0
  30. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/common.py +0 -0
  31. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/qqmusic_api/utils/tripledes.py +0 -0
  32. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_album.py +0 -0
  33. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_login.py +0 -0
  34. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_lyric.py +0 -0
  35. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_mv.py +0 -0
  36. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_qimei.py +0 -0
  37. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_search.py +0 -0
  38. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_sign.py +0 -0
  39. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_song.py +0 -0
  40. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_songlist.py +0 -0
  41. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_top.py +0 -0
  42. {qqmusic_api_python-0.3.0 → qqmusic_api_python-0.3.1}/tests/test_user.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qqmusic-api-python
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: QQ音乐API封装库
5
5
  Project-URL: homepage, https://luren-dc.github.io/QQMusicApi/
6
6
  Project-URL: repository, https://github.com/luren-dc/QQMusicApi
@@ -23,7 +23,8 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: aiocache>=0.12.3
25
25
  Requires-Dist: cryptography<44.0.2,>=44.0.1
26
- Requires-Dist: httpx>=0.27.0
26
+ Requires-Dist: httpx[http2]>=0.27.0
27
+ Requires-Dist: orjson>=3.10.15
27
28
  Requires-Dist: typing-extensions>=4.12.2
28
29
  Description-Content-Type: text/markdown
29
30
 
@@ -32,7 +33,7 @@ Description-Content-Type: text/markdown
32
33
  <img src="https://socialify.git.ci/luren-dc/QQMusicApi/image?description=1&font=Source%20Code%20Pro&language=1&logo=https%3A%2F%2Fy.qq.com%2Fmediastyle%2Fmod%2Fmobile%2Fimg%2Flogo.svg&name=1&pattern=Overlapping%20Hexagons&theme=Auto">
33
34
  </a>
34
35
  <a href="https://www.python.org">
35
- <img src="https://img.shields.io/badge/Python-3.10|3.11|3.12-blue" alt="Python">
36
+ <img src="https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13-blue" alt="Python">
36
37
  </a>
37
38
  <a href="https://github.com/luren-dc/QQMusicApi?tab=MIT-1-ov-file">
38
39
  <img src="https://img.shields.io/github/license/luren-dc/QQMusicApi" alt="GitHub license">
@@ -3,7 +3,7 @@
3
3
  <img src="https://socialify.git.ci/luren-dc/QQMusicApi/image?description=1&font=Source%20Code%20Pro&language=1&logo=https%3A%2F%2Fy.qq.com%2Fmediastyle%2Fmod%2Fmobile%2Fimg%2Flogo.svg&name=1&pattern=Overlapping%20Hexagons&theme=Auto">
4
4
  </a>
5
5
  <a href="https://www.python.org">
6
- <img src="https://img.shields.io/badge/Python-3.10|3.11|3.12-blue" alt="Python">
6
+ <img src="https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13-blue" alt="Python">
7
7
  </a>
8
8
  <a href="https://github.com/luren-dc/QQMusicApi?tab=MIT-1-ov-file">
9
9
  <img src="https://img.shields.io/github/license/luren-dc/QQMusicApi" alt="GitHub license">
@@ -7,8 +7,9 @@ authors = [
7
7
  dependencies = [
8
8
  "cryptography>=44.0.1,<44.0.2",
9
9
  "typing-extensions>=4.12.2",
10
- "httpx>=0.27.0",
10
+ "httpx[http2]>=0.27.0",
11
11
  "aiocache>=0.12.3",
12
+ "orjson>=3.10.15",
12
13
  ]
13
14
  requires-python = ">=3.10"
14
15
  readme = "README.md"
@@ -43,6 +44,10 @@ build-backend = "hatchling.build"
43
44
  [tool.uv]
44
45
  package = true
45
46
 
47
+ [[tool.uv.index]]
48
+ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
49
+ default = true
50
+
46
51
  [tool.hatch.version]
47
52
  path = "qqmusic_api/__init__.py"
48
53
 
@@ -4,7 +4,7 @@ from . import album, login, lyric, mv, search, singer, song, songlist, top, user
4
4
  from .utils.credential import Credential
5
5
  from .utils.session import Session, get_session, set_session
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.3.1"
8
8
 
9
9
  logger = logging.getLogger("qqmusicapi")
10
10
 
@@ -103,8 +103,6 @@ class QRCodeLoginEvents(Enum):
103
103
  return member
104
104
  return cls.OTHER
105
105
 
106
- ""
107
-
108
106
 
109
107
  class PhoneLoginEvents(Enum):
110
108
  """手机登录状态
@@ -3,7 +3,7 @@
3
3
  from enum import Enum
4
4
  from typing import Any, Literal, cast
5
5
 
6
- from .utils.network import api_request
6
+ from .utils.network import RequestGroup, api_request
7
7
 
8
8
 
9
9
  class AreaType(Enum):
@@ -63,21 +63,46 @@ class TabType(Enum):
63
63
  self.tab_name = tab_name
64
64
 
65
65
 
66
- # 动态生成 A-Z 的枚举值
67
- letters = {chr(i): idx for idx, i in enumerate(range(ord('A'), ord('Z') + 1), start=1)}
68
- letters.update({"ALL": -100, "HASH": 27})
69
- # 创建枚举类 IndexType
70
- IndexType = Enum('IndexType', letters)
66
+ class IndexType(Enum):
67
+ """首字母索引枚举"""
68
+
69
+ A = 1
70
+ B = 2
71
+ C = 3
72
+ D = 4
73
+ E = 5
74
+ F = 6
75
+ G = 7
76
+ H = 8
77
+ I = 9 # noqa: E741
78
+ J = 10
79
+ K = 11
80
+ L = 12
81
+ M = 13
82
+ N = 14
83
+ O = 15 # noqa: E741
84
+ P = 16
85
+ Q = 17
86
+ R = 18
87
+ S = 19
88
+ T = 20
89
+ U = 21
90
+ V = 22
91
+ W = 23
92
+ X = 24
93
+ Y = 25
94
+ Z = 26
95
+ ALL = -100
96
+ HASH = 27
71
97
 
72
98
 
73
99
  def validate_int_enum(value: int | Enum, enum_type: type[Enum]) -> int:
74
100
  """确保传入的值符合指定的枚举类型"""
75
101
  if isinstance(value, enum_type):
76
- return value.value # 如果是枚举成员,返回对应的整数值
77
- elif value in {item.value for item in enum_type}:
78
- return value # 如果是合法整数值,直接返回
79
- else:
80
- raise ValueError(f"Invalid value: {value} for {enum_type.__name__}")
102
+ return value.value # 如果是枚举成员,返回对应的整数值
103
+ if value in {item.value for item in enum_type}:
104
+ return cast(int, value) # 如果是合法整数值,直接返回
105
+ raise ValueError(f"Invalid value: {value} for {enum_type.__name__}")
81
106
 
82
107
 
83
108
  @api_request("music.musichallSinger.SingerList", "GetSingerList")
@@ -93,7 +118,6 @@ async def get_singer_list(
93
118
  sex: 性别
94
119
  genre: 风格
95
120
  """
96
-
97
121
  area = validate_int_enum(area, AreaType)
98
122
  sex = validate_int_enum(sex, SexType)
99
123
  genre = validate_int_enum(genre, GenreType)
@@ -110,7 +134,7 @@ async def get_singer_list(
110
134
 
111
135
 
112
136
  @api_request("music.musichallSinger.SingerList", "GetSingerListIndex")
113
- async def get_singer_list_index_raw(
137
+ async def get_singer_list_index(
114
138
  area: int | AreaType = AreaType.ALL,
115
139
  sex: int | SexType = SexType.ALL,
116
140
  genre: int | GenreType = GenreType.ALL,
@@ -128,7 +152,6 @@ async def get_singer_list_index_raw(
128
152
  sin: 跳过数量
129
153
  cur_page: 当前页
130
154
  """
131
-
132
155
  area = validate_int_enum(area, AreaType)
133
156
  sex = validate_int_enum(sex, SexType)
134
157
  genre = validate_int_enum(genre, GenreType)
@@ -147,29 +170,6 @@ async def get_singer_list_index_raw(
147
170
  )
148
171
 
149
172
 
150
- async def get_singer_list_index(
151
- area: int | AreaType = AreaType.ALL,
152
- sex: int | SexType = SexType.ALL,
153
- genre: int | GenreType = GenreType.ALL,
154
- index: int | IndexType = IndexType.ALL,
155
- sin: int = 0,
156
- cur_page: int = 1,
157
- ):
158
- """获取自定义页歌手列表
159
- """
160
-
161
- area = validate_int_enum(area, AreaType)
162
- sex = validate_int_enum(sex, SexType)
163
- genre = validate_int_enum(genre, GenreType)
164
- index = validate_int_enum(index, IndexType)
165
-
166
- data = await get_singer_list_index_raw(
167
- area = area, sex = sex, genre = genre, index = index, sin = sin, cur_page = cur_page
168
- )
169
-
170
- return cast(list[dict[str, Any]], data["singerlist"])
171
-
172
-
173
173
  async def get_singer_list_index_all(
174
174
  area: int | AreaType = AreaType.ALL,
175
175
  sex: int | SexType = SexType.ALL,
@@ -177,32 +177,35 @@ async def get_singer_list_index_all(
177
177
  index: int | IndexType = IndexType.ALL,
178
178
  ):
179
179
  """获取所有歌手列表
180
- """
181
180
 
181
+ Args:
182
+ area: 地区
183
+ sex: 性别
184
+ genre: 风格
185
+ index: 索引
186
+ """
182
187
  area = validate_int_enum(area, AreaType)
183
188
  sex = validate_int_enum(sex, SexType)
184
189
  genre = validate_int_enum(genre, GenreType)
185
190
  index = validate_int_enum(index, IndexType)
186
191
 
187
- data = await get_singer_list_index_raw(
188
- area = area, sex = sex, genre = genre, index = index, sin = 0, cur_page = 1
189
- )
192
+ data = await get_singer_list_index(area=area, sex=sex, genre=genre, index=index, sin=0, cur_page=1)
190
193
 
191
194
  singer_list = data["singerlist"]
192
195
  total = data["total"]
193
196
  if total <= 80:
194
197
  return cast(list[dict[str, Any]], singer_list)
195
198
 
196
- # 每页80个歌手,向下取整
199
+ # 每页80个歌手,向下取整
197
200
  pages = total // 80
198
201
  sin = 80
202
+ rg = RequestGroup()
199
203
  for page in range(2, pages + 2):
200
- data = await get_singer_list_index_raw(
201
- area = area, sex = sex, genre = genre, index = index, sin = sin, cur_page = page
202
- )
203
- singer_list.extend(data["singerlist"])
204
+ rg.add_request(get_singer_list_index, area=area, sex=sex, genre=genre, index=index, sin=sin, cur_page=page)
204
205
  sin += 80
205
206
 
207
+ for data in await rg.execute():
208
+ singer_list.extend(data["singerlist"])
206
209
  return cast(list[dict[str, Any]], singer_list)
207
210
 
208
211
 
@@ -252,6 +255,17 @@ async def get_desc(mids: list[str]):
252
255
  return {"singer_mids": mids, "groups": 1, "wikis": 1}, lambda data: cast(list[dict[str, Any]], data["singer_list"])
253
256
 
254
257
 
258
+ @api_request("music.SimilarSingerSvr", "GetSimilarSingerList")
259
+ async def get_similar(mid: str, number: int = 10):
260
+ """获取类似歌手列表
261
+
262
+ Args:
263
+ mid: 歌手 mid
264
+ number: 类似歌手数量
265
+ """
266
+ return {"singerMid": mid, "number": number}, lambda data: cast(list[dict[str, Any]], data["singerlist"])
267
+
268
+
255
269
  async def get_songs(
256
270
  mid: str,
257
271
  tab_type: Literal[
@@ -275,3 +289,135 @@ async def get_songs(
275
289
  num: 返回数量
276
290
  """
277
291
  return await get_tab_detail(mid, tab_type, page, num)
292
+
293
+
294
+ @api_request("musichall.song_list_server", "GetSingerSongList")
295
+ async def get_songs_list(mid: str, number: int = 10, begin: int = 0):
296
+ """获取歌手歌曲原始数据
297
+
298
+ Args:
299
+ mid: 歌手 mid
300
+ number: 每次获取数量,最大30
301
+ begin: 从第几个开始
302
+ """
303
+ return {
304
+ "singerMid": mid,
305
+ "order": 1,
306
+ "number": number,
307
+ "begin": begin,
308
+ }, lambda data: cast(
309
+ dict[str, Any],
310
+ data,
311
+ )
312
+
313
+
314
+ async def get_songs_list_all(mid: str):
315
+ """获取歌手所有歌曲列表
316
+
317
+ Args:
318
+ mid: 歌手 mid
319
+ """
320
+ response = await get_songs_list(mid=mid, number=30, begin=0)
321
+
322
+ total = response["totalNum"]
323
+ songs = [song["songInfo"] for song in response["songList"]]
324
+ if total <= 30:
325
+ return cast(list[dict[str, Any]], songs)
326
+
327
+ rg = RequestGroup()
328
+ for num in range(30, total, 30):
329
+ rg.add_request(get_songs_list, mid=mid, number=30, begin=num)
330
+
331
+ response = await rg.execute()
332
+ for res in response:
333
+ songs.extend([song["songInfo"] for song in res["songList"]])
334
+
335
+ return cast(list[dict[str, Any]], songs)
336
+
337
+
338
+ @api_request("music.musichallAlbum.AlbumListServer", "GetAlbumList")
339
+ async def get_album_list(mid: str, number: int = 10, begin: int = 0):
340
+ """获取歌手专辑
341
+
342
+ Args:
343
+ mid: 歌手 mid
344
+ number: 每次获取数量,不足30个的时候直接全部返回
345
+ begin: 从第几个开始
346
+ """
347
+ return {
348
+ "singerMid": mid,
349
+ "order": 1,
350
+ "number": number,
351
+ "begin": begin,
352
+ }, lambda data: cast(
353
+ dict[str, Any],
354
+ data,
355
+ )
356
+
357
+
358
+ async def get_album_list_all(mid: str):
359
+ """获取歌手所有专辑列表
360
+
361
+ Args:
362
+ mid: 歌手 mid
363
+ """
364
+ response = await get_album_list(mid=mid, number=30, begin=0)
365
+
366
+ total = response["total"]
367
+ albums = response["albumList"]
368
+ if total <= 30:
369
+ return cast(list[dict[str, Any]], albums)
370
+
371
+ rg = RequestGroup()
372
+ for num in range(30, total, 30):
373
+ rg.add_request(get_album_list, mid=mid, number=30, begin=num)
374
+
375
+ response = await rg.execute()
376
+ for res in response:
377
+ albums.extend(res["albumList"])
378
+
379
+ return cast(list[dict[str, Any]], albums)
380
+
381
+
382
+ @api_request("MvService.MvInfoProServer", "GetSingerMvList")
383
+ async def get_mv_list(mid: str, number: int = 10, begin: int = 0):
384
+ """获取歌手mv原始数据
385
+
386
+ Args:
387
+ mid: 歌手 mid
388
+ number: 每次获取数量,每次最大100
389
+ begin: 从第几个开始
390
+ """
391
+ return {
392
+ "singermid": mid,
393
+ "order": 1,
394
+ "count": number,
395
+ "start": begin,
396
+ }, lambda data: cast(
397
+ dict[str, Any],
398
+ data,
399
+ )
400
+
401
+
402
+ async def get_mv_list_all(mid: str):
403
+ """获取歌手所有专辑列表
404
+
405
+ Args:
406
+ mid: 歌手 mid
407
+ """
408
+ response = await get_mv_list(mid=mid, number=100, begin=0)
409
+
410
+ total = response["total"]
411
+ mvs = response["list"]
412
+ if total <= 100:
413
+ return cast(list[dict[str, Any]], mvs)
414
+
415
+ rg = RequestGroup()
416
+ for num in range(100, total, 100):
417
+ rg.add_request(get_mv_list, mid=mid, number=100, begin=num)
418
+
419
+ response = await rg.execute()
420
+ for res in response:
421
+ mvs.extend(res["list"])
422
+
423
+ return cast(list[dict[str, Any]], mvs)
@@ -179,7 +179,6 @@ async def get_song_urls(
179
179
  params=params,
180
180
  credential=credential,
181
181
  exclude_params=["guid"],
182
- cacheable=False,
183
182
  )
184
183
  req.processor = _processor
185
184
  rg.add_request(req)
@@ -1,10 +1,12 @@
1
1
  """凭据类,用于请求验证"""
2
2
 
3
- import json
4
3
  import sys
5
4
  from dataclasses import asdict, dataclass, field
5
+ from time import time
6
6
  from typing import Any
7
7
 
8
+ import orjson as json
9
+
8
10
  if sys.version_info >= (3, 11):
9
11
  from typing import Self
10
12
  else:
@@ -87,6 +89,12 @@ class Credential:
87
89
 
88
90
  async def is_expired(self) -> bool:
89
91
  """判断 credential 是否过期"""
92
+ if "musickeyCreateTime" in self.extra_fields and "keyExpiresIn" in self.extra_fields:
93
+ expired_time_stamp = self.extra_fields["musickeyCreateTime"] + self.extra_fields["keyExpiresIn"]
94
+ if expired_time_stamp >= time():
95
+ return True
96
+ return False
97
+
90
98
  from ..login import check_expired
91
99
 
92
100
  return await check_expired(self)
@@ -102,7 +110,7 @@ class Credential:
102
110
  """获取凭据 JSON 字符串"""
103
111
  data = self.as_dict()
104
112
  data.update(data.pop("extra_fields"))
105
- return json.dumps(data, indent=4, ensure_ascii=False)
113
+ return json.dumps(data).decode()
106
114
 
107
115
  @classmethod
108
116
  def from_cookies_dict(cls, cookies: dict[str, Any]) -> Self:
@@ -112,7 +120,7 @@ class Credential:
112
120
  refresh_token=cookies.pop("refresh_token", ""),
113
121
  access_token=cookies.pop("access_token", ""),
114
122
  expired_at=cookies.pop("expired_at", 0),
115
- musicid=cookies.pop("musicid", 0),
123
+ musicid=int(cookies.pop("musicid", 0)),
116
124
  musickey=cookies.pop("musickey", ""),
117
125
  unionid=cookies.pop("unionid", ""),
118
126
  str_musicid=cookies.pop(
@@ -2,7 +2,6 @@
2
2
 
3
3
  import binascii
4
4
  import hashlib
5
- import json
6
5
  import random
7
6
  import string
8
7
  from dataclasses import asdict, dataclass, field
@@ -10,6 +9,8 @@ from pathlib import Path
10
9
  from typing import ClassVar
11
10
  from uuid import uuid4
12
11
 
12
+ import orjson as json
13
+
13
14
  device_path = Path(__file__).parent.parent / ".cache" / "device.json"
14
15
 
15
16
 
@@ -97,4 +98,4 @@ def get_cached_device() -> Device:
97
98
  def save_device(device: Device):
98
99
  """缓存 Device"""
99
100
  device_path.parent.mkdir(parents=True, exist_ok=True)
100
- device_path.write_text(json.dumps(asdict(device)))
101
+ device_path.write_text(json.dumps(asdict(device)).decode())
@@ -1,6 +1,5 @@
1
1
  """网络请求"""
2
2
 
3
- import json
4
3
  import logging
5
4
  from abc import ABC, abstractmethod
6
5
  from collections import defaultdict
@@ -8,6 +7,7 @@ from collections.abc import Callable, Coroutine
8
7
  from typing import Any, ClassVar, Generic, ParamSpec, TypedDict, TypeVar, cast
9
8
 
10
9
  import httpx
10
+ import orjson as json
11
11
  from typing_extensions import override
12
12
 
13
13
  from ..exceptions import CredentialExpiredError, ResponseCodeError, SignInvalidError
@@ -246,7 +246,7 @@ class ApiRequest(BaseRequest, Generic[_P, _R]):
246
246
  params.pop(key, None)
247
247
  if self.credential:
248
248
  params["credential"] = f"{self.credential.musicid}{self.credential.musickey}"
249
- sorted_params = json.dumps(params, sort_keys=True, ensure_ascii=False)
249
+ sorted_params = json.dumps(params, option=json.OPT_SORT_KEYS)
250
250
  return calc_md5(sorted_params)
251
251
 
252
252
  @override
@@ -255,7 +255,7 @@ class ApiRequest(BaseRequest, Generic[_P, _R]):
255
255
  if not resp.content:
256
256
  return {}
257
257
  try:
258
- data = resp.json()
258
+ data = json.loads(resp.content)
259
259
  except json.JSONDecodeError:
260
260
  return {"data": resp.text}
261
261
  req_data = data.get(f"{self.module}.{self.method}", {})
@@ -327,9 +327,11 @@ class RequestGroup(BaseRequest):
327
327
  self,
328
328
  common: dict[str, Any] | None = None,
329
329
  credential: Credential | None = None,
330
+ limit: int = 30,
330
331
  ):
331
332
  super().__init__(common, credential)
332
333
  self._requests: list[RequestItem] = []
334
+ self.limit = limit
333
335
  self._key_counter = defaultdict(int)
334
336
  self._results = []
335
337
 
@@ -358,11 +360,11 @@ class RequestGroup(BaseRequest):
358
360
  if not resp.content:
359
361
  return []
360
362
 
361
- data = resp.json()
363
+ res_data = json.loads(resp.content)
362
364
 
363
365
  for req_item in self._requests:
364
366
  req = req_item["request"]
365
- req_data = data.get(req_item["key"], {})
367
+ req_data = res_data.get(req_item["key"], {})
366
368
  req._validate_response(req_data)
367
369
  if req_item["processor"]:
368
370
  data = req_item["processor"](req_data.get("data", req_data))
@@ -403,7 +405,7 @@ class RequestGroup(BaseRequest):
403
405
  remove_index.append(idx)
404
406
  self._requests = [req for idx, req in enumerate(self._requests) if idx not in remove_index]
405
407
 
406
- async def execute(self) -> list[Any]:
408
+ async def _execute(self) -> list[Any]:
407
409
  """执行合并请求并返回各请求结果"""
408
410
  if not self._requests:
409
411
  return []
@@ -418,3 +420,33 @@ class RequestGroup(BaseRequest):
418
420
  resp = await self.request()
419
421
  await self._process_response(resp)
420
422
  return self._results
423
+
424
+ async def execute(self) -> list[Any]:
425
+ """执行合并请求"""
426
+ if not self._requests:
427
+ return []
428
+
429
+ # 未设置 limit 或请求数未超过 limit 时直接处理
430
+ if self.limit <= 0 or len(self._requests) <= self.limit:
431
+ return await self._execute()
432
+
433
+ # 分批次处理
434
+ batches = [self._requests[i : i + self.limit] for i in range(0, len(self._requests), self.limit)]
435
+ all_results = []
436
+
437
+ for batch in batches:
438
+ # 创建新 RequestGroup 处理当前批次
439
+ batch_group = RequestGroup(
440
+ common=self.common.copy(),
441
+ credential=self.credential,
442
+ )
443
+
444
+ # 添加当前批次的请求
445
+ for req_item in batch:
446
+ batch_group.add_request(req_item["request"], *req_item["args"], **req_item["kwargs"])
447
+
448
+ # 执行并收集结果
449
+ batch_results = await batch_group._execute()
450
+ all_results.extend(batch_results)
451
+
452
+ return all_results
@@ -1,7 +1,6 @@
1
1
  """QIMEI 获取"""
2
2
 
3
3
  import base64
4
- import json
5
4
  import logging
6
5
  import random
7
6
  from datetime import datetime, timedelta
@@ -9,6 +8,7 @@ from time import time
9
8
  from typing import TypedDict, cast
10
9
 
11
10
  import httpx
11
+ import orjson as json
12
12
  from cryptography.hazmat.primitives import serialization
13
13
  from cryptography.hazmat.primitives.asymmetric import padding
14
14
  from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
@@ -112,7 +112,7 @@ def random_payload_by_device(device: Device, version: str) -> dict:
112
112
  "packageId": "com.tencent.qqmusic",
113
113
  "deviceType": "Phone",
114
114
  "sdkName": "",
115
- "reserved": json.dumps(reserved, separators=(",", ":"), ensure_ascii=False),
115
+ "reserved": json.dumps(reserved),
116
116
  }
117
117
 
118
118
 
@@ -125,7 +125,7 @@ def get_qimei(version: str) -> QimeiResult:
125
125
  nonce = "".join(random.choices("adbcdef1234567890", k=16))
126
126
  ts = int(time())
127
127
  key = base64.b64encode(rsa_encrypt(crypt_key.encode())).decode()
128
- params = base64.b64encode(aes_encrypt(crypt_key.encode(), json.dumps(payload).encode())).decode()
128
+ params = base64.b64encode(aes_encrypt(crypt_key.encode(), json.dumps(payload))).decode()
129
129
  extra = '{"appKey":"' + APP_KEY + '"}'
130
130
  sign = calc_md5(key, params, str(ts * 1000), nonce, SECRET, extra)
131
131
  res = httpx.post(
@@ -154,7 +154,7 @@ def get_qimei(version: str) -> QimeiResult:
154
154
  timeout=5,
155
155
  )
156
156
  logger.debug("获取 QIMEI 成功: %s", res.json())
157
- data = json.loads(res.json()["data"])["data"]
157
+ data = json.loads(json.loads(res.content)["data"])["data"]
158
158
  device.qimei = data["q36"]
159
159
  save_device(device)
160
160
  return QimeiResult(q16=data["q16"], q36=data["q36"])
@@ -4,8 +4,9 @@ import contextvars
4
4
  from typing import TypedDict
5
5
 
6
6
  import httpx
7
+ import orjson as json
7
8
  from aiocache import Cache
8
- from aiocache.serializers import JsonSerializer
9
+ from aiocache.serializers import BaseSerializer
9
10
 
10
11
  from .credential import Credential
11
12
  from .qimei import get_qimei
@@ -21,6 +22,20 @@ class ApiConfig(TypedDict):
21
22
  enc_endpoint: str
22
23
 
23
24
 
25
+ class ORJsonSerializer(BaseSerializer):
26
+ """Transform data to json string with json.dumps and json.loads to retrieve it back."""
27
+
28
+ def dumps(self, value):
29
+ """Serialize the received value using ``json.dumps``."""
30
+ return json.dumps(value, option=json.OPT_NON_STR_KEYS).decode()
31
+
32
+ def loads(self, value):
33
+ """Deserialize value using ``json.loads``."""
34
+ if value is None:
35
+ return None
36
+ return json.loads(value)
37
+
38
+
24
39
  class Session(httpx.AsyncClient):
25
40
  """Session 类,用于管理 QQ 音乐的登录态和 API 请求
26
41
 
@@ -41,9 +56,10 @@ class Session(httpx.AsyncClient):
41
56
  enable_sign: bool = False,
42
57
  enable_cache: bool = True,
43
58
  cache_ttl: int = 120,
59
+ http2: bool = True,
44
60
  **kwargs,
45
61
  ) -> None:
46
- super().__init__(**kwargs)
62
+ super().__init__(**kwargs, http2=http2)
47
63
  self.credential = credential
48
64
  self.headers.update(
49
65
  {
@@ -59,9 +75,15 @@ class Session(httpx.AsyncClient):
59
75
  enc_endpoint="https://u.y.qq.com/cgi-bin/musics.fcg",
60
76
  )
61
77
  self.enable_cache = enable_cache
62
- self._cache = Cache(serializer=JsonSerializer(), ttl=cache_ttl)
78
+ self._cache = Cache(serializer=ORJsonSerializer(), ttl=cache_ttl)
63
79
  self.qimei = get_qimei(self.api_config["version"])["q36"]
64
80
 
81
+ async def clear_cache(self):
82
+ """清除API请求缓存"""
83
+ if not self.enable_cache:
84
+ return
85
+ await self._cache.clear()
86
+
65
87
  async def __aenter__(self) -> "Session":
66
88
  """进入 async with 上下文时调用"""
67
89
  self._previous_session = _session_context.set(self)
@@ -1,7 +1,8 @@
1
1
  """QQ音乐 sign"""
2
2
 
3
3
  import base64
4
- import json
4
+
5
+ import orjson as json
5
6
 
6
7
  from .common import calc_md5
7
8
 
@@ -56,7 +57,7 @@ def sign(request: dict) -> str:
56
57
  Returns:
57
58
  签名结果
58
59
  """
59
- md5_str = calc_md5(json.dumps(request, ensure_ascii=False, separators=(",", ":"))).upper().encode("utf-8")
60
+ md5_str = calc_md5(json.dumps(request)).upper().encode("utf-8")
60
61
 
61
62
  h = _head(md5_str)
62
63
  e = _tail(md5_str)
@@ -41,7 +41,7 @@ def test_same_thread_different_loops():
41
41
 
42
42
  async def use_session(session):
43
43
  assert global_session == session
44
- await session.get("https://baidu.com")
44
+ await session.get("https://m.baidu.com/")
45
45
 
46
46
  loop = asyncio.new_event_loop()
47
47
  try:
@@ -0,0 +1,62 @@
1
+ import pytest
2
+
3
+ from qqmusic_api import singer
4
+
5
+ pytestmark = pytest.mark.asyncio(loop_scope="session")
6
+
7
+
8
+ async def test_get_singer_list():
9
+ assert await singer.get_singer_list()
10
+
11
+
12
+ async def test_get_singer_list_index():
13
+ assert await singer.get_singer_list_index()
14
+
15
+
16
+ async def test_get_singer_list_index_all():
17
+ assert await singer.get_singer_list_index_all(index=1, area=5, sex=0)
18
+
19
+
20
+ async def test_get_info():
21
+ assert await singer.get_info(mid="0025NhlN2yWrP4")
22
+
23
+
24
+ async def test_get_tab_detail():
25
+ for tab_type in singer.TabType:
26
+ assert await singer.get_tab_detail(mid="0025NhlN2yWrP4", tab_type=tab_type)
27
+
28
+
29
+ async def test_get_desc():
30
+ assert await singer.get_desc(mids=["0025NhlN2yWrP4"])
31
+
32
+
33
+ async def test_get_similar():
34
+ assert await singer.get_similar(mid="003zdDsO1e1ZXu")
35
+
36
+
37
+ async def test_get_song():
38
+ assert await singer.get_songs(mid="0025NhlN2yWrP4")
39
+
40
+
41
+ async def test_get_songs_list():
42
+ assert await singer.get_songs_list(mid="003zdDsO1e1ZXu", number=20, begin=0)
43
+
44
+
45
+ async def test_get_songs_list_all():
46
+ assert await singer.get_songs_list_all(mid="003zdDsO1e1ZXu")
47
+
48
+
49
+ async def test_get_album_list():
50
+ assert await singer.get_album_list(mid="0025NhlN2yWrP4")
51
+
52
+
53
+ async def test_get_album_list_all():
54
+ assert await singer.get_album_list_all(mid="0025NhlN2yWrP4")
55
+
56
+
57
+ async def test_get_mv_list():
58
+ assert await singer.get_mv_list(mid="001orhmd37wwf2")
59
+
60
+
61
+ async def test_get_mv_list_all():
62
+ assert await singer.get_mv_list_all(mid="001orhmd37wwf2")
@@ -1,51 +0,0 @@
1
- import httpx
2
- import pytest
3
-
4
- from qqmusic_api.utils.network import ApiRequest, RequestGroup
5
-
6
- pytestmark = pytest.mark.asyncio(loop_scope="session")
7
-
8
-
9
- @pytest.fixture
10
- def mock_client(monkeypatch):
11
- async def mock_post(*args, **kwargs):
12
- class MockResponse:
13
- def __init__(self, json_data, status_code=200):
14
- self.json_data = json_data
15
- self.status_code = status_code
16
- self.content = json_data
17
-
18
- def json(self):
19
- return self.json_data
20
-
21
- def raise_for_status(self):
22
- pass
23
-
24
- data = kwargs.get("json", {})
25
- data.pop("comm", None)
26
- return MockResponse({key: {"code": 0, "data": {"result": "success"}} for key in data.keys()})
27
-
28
- monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
29
-
30
-
31
- async def test_api_request(mock_client):
32
- api = ApiRequest(
33
- module="music.smartboxCgi.SmartBoxCgi",
34
- method="GetSmartBoxResult",
35
- params={"query": "test"},
36
- )
37
- response = await api()
38
- assert response == {"result": "success"}
39
-
40
-
41
- async def test_request_group(mock_client):
42
- group = RequestGroup()
43
- api = ApiRequest(
44
- module="music.smartboxCgi.SmartBoxCgi",
45
- method="GetSmartBoxResult",
46
- params={"query": "test"},
47
- )
48
- group.add_request(api)
49
- group.add_request(api)
50
- results = await group.execute()
51
- assert results == [{"result": "success"}, {"result": "success"}]
@@ -1,34 +0,0 @@
1
- import pytest
2
-
3
- from qqmusic_api import singer
4
-
5
- pytestmark = pytest.mark.asyncio(loop_scope="session")
6
-
7
-
8
- async def test_get_singer_list():
9
- assert await singer.get_singer_list()
10
-
11
-
12
- async def test_get_singer_list_index():
13
- assert await singer.get_singer_list_index()
14
-
15
-
16
- async def test_get_singer_list_index_all():
17
- assert await singer.get_singer_list_index_all(index = 1, area = 5, sex = 0)
18
-
19
-
20
- async def test_get_info():
21
- assert await singer.get_info(mid="0025NhlN2yWrP4")
22
-
23
-
24
- async def test_get_tab_detail():
25
- for tab_type in singer.TabType:
26
- assert await singer.get_tab_detail(mid="0025NhlN2yWrP4", tab_type=tab_type)
27
-
28
-
29
- async def test_get_desc():
30
- assert await singer.get_desc(mids=["0025NhlN2yWrP4"])
31
-
32
-
33
- async def test_get_song():
34
- assert await singer.get_songs(mid="0025NhlN2yWrP4")