fuo-qqmusic 1.0.4__tar.gz → 1.0.6__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.

Potentially problematic release.


This version of fuo-qqmusic might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: fuo_qqmusic
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: feeluown qqmusic plugin
5
5
  Home-page: https://github.com/feeluown/feeluown-qqmusic
6
6
  Author: Cosven
@@ -13,3 +13,13 @@ Classifier: Programming Language :: Python :: 3.7
13
13
  Classifier: Programming Language :: Python :: 3.8
14
14
  Classifier: Programming Language :: Python :: 3.9
15
15
  Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Dist: feeluown>=4.1.3
17
+ Requires-Dist: requests
18
+ Requires-Dist: marshmallow>=3.0
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: home-page
23
+ Dynamic: keywords
24
+ Dynamic: requires-dist
25
+ Dynamic: summary
@@ -20,6 +20,14 @@ pip3 install fuo-qqmusic
20
20
  [操作示例](https://github.com/feeluown/feeluown-qqmusic/issues/6)。
21
21
 
22
22
  ## changelog
23
+ ### 1.0.6 (2025-05-08)
24
+ - 支持每日推荐
25
+ - 支持自动登录
26
+
27
+ ### 1.0.5 (2024-06-03)
28
+ - 修复搜索接口,支持搜索歌单、视频、专辑
29
+ - 支持提供歌单的播放次数
30
+
23
31
  ### 1.0.4 (2024-05-21)
24
32
  - 歌手歌曲排序切换为”按热度排序”
25
33
  - 修复推荐歌单接口
@@ -58,7 +58,7 @@ class API(object):
58
58
  Please http capture request from (mobile) qqmusic mobile web page
59
59
  """
60
60
 
61
- def __init__(self, timeout=1):
61
+ def __init__(self, timeout=2):
62
62
  # TODO: 暂时无脑统一一个 timeout
63
63
  # 正确的应该是允许不同接口有不同的超时时间
64
64
  self._timeout = timeout
@@ -124,10 +124,14 @@ class API(object):
124
124
  # Other supported types: songlist, user, mv, qc, gedantip, zhida.
125
125
  if type_ == 0:
126
126
  key_ = 'song'
127
- elif type_ == 8:
128
- key_ = 'album'
129
- elif type_ == 9:
127
+ elif type_ == 1:
130
128
  key_ = 'singer'
129
+ elif type_ == 2:
130
+ key_ = 'album'
131
+ elif type_ == 3:
132
+ key_ = 'songlist' # playlist
133
+ elif type_ == 4:
134
+ key_ = 'mv' # video
131
135
  else:
132
136
  raise QQIOError('invalid search type_:%d', type_)
133
137
  payload = {
@@ -514,6 +518,33 @@ class API(object):
514
518
  js = self.rpc(payload)
515
519
  return js['songlist']['data']['tracks']
516
520
 
521
+ def get_diss_info(self, dissid, offset=0, limit=50):
522
+ """获取歌单的详情
523
+
524
+ 这是一种特殊的歌单,它的内容是动态的,由官方生成。比如“百万收藏”。
525
+
526
+ :param dissid: int, 举个例子 211111 是百万收藏。
527
+ :return: 参考 fixtures/get_diss_info.json
528
+ """
529
+ payload = {
530
+ 'req': {
531
+ "module": "music.srfDissInfo.aiDissInfo",
532
+ "method":"uniform_get_Dissinfo",
533
+ "param": {
534
+ "disstid":dissid,
535
+ "userinfo":1, # 不懂啥意思
536
+ "tag":1, # 不懂啥意思
537
+ "orderlist":1,
538
+ "song_begin": offset,
539
+ "song_num": limit,
540
+ "onlysonglist":0,
541
+ "enc_host_uin":"" # 注:即使登录了,这个也是空
542
+ }
543
+ }
544
+ }
545
+ js = self.rpc(payload)
546
+ return js['req']['data']
547
+
517
548
  def get_song_url(self, song_mid):
518
549
  uin = self._uin
519
550
  songvkey = str(random.random()).replace("0.", "")
@@ -0,0 +1,24 @@
1
+ import json
2
+ import os
3
+
4
+ from feeluown.consts import DATA_DIR
5
+
6
+ USER_INFO_FILE = DATA_DIR + '/qqmusic_user_info.json'
7
+
8
+
9
+ def read_cookies():
10
+ if os.path.exists(USER_INFO_FILE):
11
+ # if the file is broken, just raise error
12
+ with open(USER_INFO_FILE) as f:
13
+ return json.load(f).get('cookies', None)
14
+
15
+
16
+ def write_cookies(user, cookies):
17
+ js = {
18
+ 'identifier': user.identifier,
19
+ 'name': user.name,
20
+ 'cookies': cookies
21
+ }
22
+ with open(USER_INFO_FILE, 'w') as f:
23
+ json.dump(js, f, indent=2)
24
+
@@ -1,5 +1,5 @@
1
1
  import logging
2
- from typing import List, Optional, Protocol
2
+ from typing import List, Optional, Protocol, Tuple
3
3
  from feeluown.excs import ModelNotFound
4
4
  from feeluown.library import (
5
5
  AbstractProvider,
@@ -18,6 +18,7 @@ from feeluown.library import (
18
18
  SupportsVideoGet,
19
19
  SupportsSongLyric,
20
20
  SupportsAlbumGet,
21
+ SupportsAlbumSongsReader,
21
22
  SupportsArtistGet,
22
23
  SupportsPlaylistGet,
23
24
  SupportsPlaylistSongsReader,
@@ -25,10 +26,13 @@ from feeluown.library import (
25
26
  SimpleSearchResult,
26
27
  SearchType,
27
28
  ModelType,
29
+ UserModel,
28
30
  )
29
31
  from feeluown.media import Media, Quality
30
32
  from feeluown.utils.reader import create_reader, SequentialReader
31
33
  from .api import API
34
+ from .login import read_cookies
35
+ from .excs import QQIOError
32
36
 
33
37
 
34
38
  logger = logging.getLogger(__name__)
@@ -48,6 +52,7 @@ class Supports(
48
52
  SupportsPlaylistGet,
49
53
  SupportsPlaylistSongsReader,
50
54
  SupportsRecACollectionOfSongs,
55
+ SupportsAlbumSongsReader,
51
56
  Protocol,
52
57
  ):
53
58
  pass
@@ -77,6 +82,31 @@ class QQProvider(AbstractProvider, ProviderV2):
77
82
  def name(self):
78
83
  return "QQ 音乐"
79
84
 
85
+ def auto_login(self):
86
+ cookies = read_cookies()
87
+ user, err = self.try_get_user_from_cookies(cookies)
88
+ if user:
89
+ self.auth(user)
90
+ else:
91
+ logger.info(f'Auto login failed: {err}')
92
+
93
+ def try_get_user_from_cookies(self, cookies) -> Tuple[Optional[UserModel], str]:
94
+ if not cookies: # is None or empty
95
+ return None, 'empty cookies'
96
+
97
+ uin = provider.api.get_uin_from_cookies(cookies)
98
+ if uin is None:
99
+ return None, "can't extract user info from cookies"
100
+
101
+ provider.api.set_cookies(cookies)
102
+ # try to extract current user
103
+ try:
104
+ user = provider.user_get(uin)
105
+ except QQIOError:
106
+ provider.api.set_cookies(None)
107
+ return None, 'get user info with cookies failed, expired cookies?'
108
+ return user, ''
109
+
80
110
  def use_model_v2(self, mtype):
81
111
  return mtype in (
82
112
  ModelType.song,
@@ -224,6 +254,10 @@ class QQProvider(AbstractProvider, ProviderV2):
224
254
  album = _deserialize(data_album, QQAlbumSchema)
225
255
  return album
226
256
 
257
+ def album_create_songs_rd(self, album):
258
+ album = self.album_get(album.identifier)
259
+ return create_reader(album.songs)
260
+
227
261
  def user_get(self, identifier):
228
262
  data = self.api.user_detail(identifier)
229
263
  data["creator"]["fav_pid"] = data["mymusic"][0]["id"]
@@ -253,6 +287,27 @@ class QQProvider(AbstractProvider, ProviderV2):
253
287
  pl["logo"] = pl["cover"]
254
288
  return [_deserialize(playlist, QQPlaylistSchema) for playlist in playlists]
255
289
 
290
+ def rec_list_daily_songs(self):
291
+ # TODO: cache API result
292
+ feed = self.api.get_recommend_feed()
293
+ card = None
294
+ for shelf_ in feed['v_shelf']:
295
+ if 'moduleID' not in shelf_['extra_info']:
296
+ for batch in shelf_['v_niche']:
297
+ for card in batch['v_card']:
298
+ if (
299
+ card['extra_info'].get('moduleID', '').startswith('recforyou')
300
+ and card['jumptype'] == 10014 # 10014->playlist
301
+ ):
302
+ card = card
303
+ break
304
+ if card is None:
305
+ logger.warning("No daily songs found")
306
+ return []
307
+ playlist_id = card['id']
308
+ playlist = self.playlist_get(playlist_id)
309
+ return self.playlist_create_songs_rd(playlist).readall()
310
+
256
311
  def rec_list_daily_playlists(self):
257
312
  # TODO: cache API result
258
313
  feed = self.api.get_recommend_feed()
@@ -267,14 +322,14 @@ class QQProvider(AbstractProvider, ProviderV2):
267
322
  playlists = []
268
323
  for batch in shelf['v_niche']:
269
324
  for card in batch['v_card']:
270
- print(card['title'], card['jumptype'])
271
325
  if card['jumptype'] == 10014: # 10014->playlist
272
326
  playlists.append(
273
327
  PlaylistModel(identifier=str(card['id']),
274
328
  source=SOURCE,
275
329
  name=card['title'],
276
330
  cover=card['cover'],
277
- description=card['miscellany']['rcmdtemplate'])
331
+ description=card['miscellany']['rcmdtemplate'],
332
+ play_count=card['cnt'])
278
333
  )
279
334
  return playlists
280
335
 
@@ -394,26 +449,29 @@ def create_g(func, identifier, schema):
394
449
 
395
450
  def search(keyword, **kwargs):
396
451
  type_ = SearchType.parse(kwargs["type_"])
397
- if type_ == SearchType.pl:
398
- data = provider.api.search_playlists(keyword)
399
- playlists = [_deserialize(playlist, _BriefPlaylistSchema) for playlist in data]
452
+ type_type_map = {
453
+ SearchType.so: 0,
454
+ SearchType.ar: 1,
455
+ SearchType.al: 2,
456
+ SearchType.pl: 3,
457
+ SearchType.vi: 4,
458
+ }
459
+ data = provider.api.search(keyword, type_=type_type_map[type_])
460
+ if type_ == SearchType.so:
461
+ songs = [_deserialize(song, QQSongSchema) for song in data]
462
+ return SimpleSearchResult(q=keyword, songs=songs)
463
+ if type_ == SearchType.ar:
464
+ artists = [_deserialize(artist, SearchArtistSchema) for artist in data]
465
+ return SimpleSearchResult(q=keyword, artists=artists)
466
+ elif type_ == SearchType.al:
467
+ albums = [_deserialize(album, SearchAlbumSchema) for album in data]
468
+ return SimpleSearchResult(q=keyword, albums=albums)
469
+ elif type_ == SearchType.pl:
470
+ playlists = [_deserialize(playlist, SearchPlaylistSchema) for playlist in data]
400
471
  return SimpleSearchResult(q=keyword, playlists=playlists)
401
- else:
402
- type_type_map = {
403
- SearchType.so: 0,
404
- SearchType.al: 8,
405
- SearchType.ar: 9,
406
- }
407
- data = provider.api.search(keyword, type_=type_type_map[type_])
408
- if type_ == SearchType.so:
409
- songs = [_deserialize(song, QQSongSchema) for song in data]
410
- return SimpleSearchResult(q=keyword, songs=songs)
411
- elif type_ == SearchType.al:
412
- albums = [_deserialize(album, _BriefAlbumSchema) for album in data]
413
- return SimpleSearchResult(q=keyword, albums=albums)
414
- else:
415
- artists = [_deserialize(artist, _BriefArtistSchema) for artist in data]
416
- return SimpleSearchResult(q=keyword, artists=artists)
472
+ elif type_ == SearchType.vi:
473
+ models = [_deserialize(model, SearchMVSchema) for model in data]
474
+ return SimpleSearchResult(q=keyword, videos=models)
417
475
 
418
476
 
419
477
  provider = QQProvider()
@@ -427,9 +485,12 @@ from .schemas import ( # noqa
427
485
  _BriefAlbumSchema,
428
486
  _UserArtistSchema,
429
487
  _BriefArtistSchema,
430
- _BriefPlaylistSchema,
431
488
  QQAlbumSchema,
432
489
  QQPlaylistSchema,
433
490
  QQUserSchema,
434
491
  _UserAlbumSchema,
492
+ SearchAlbumSchema,
493
+ SearchArtistSchema,
494
+ SearchPlaylistSchema,
495
+ SearchMVSchema,
435
496
  ) # noqa
@@ -11,20 +11,11 @@ from feeluown.app.gui_app import GuiApp
11
11
 
12
12
  from .provider import provider
13
13
  from .excs import QQIOError
14
+ from .login import read_cookies, write_cookies
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
18
- USER_INFO_FILE = DATA_DIR + '/qqmusic_user_info.json'
19
-
20
-
21
- def read_cookies():
22
- if os.path.exists(USER_INFO_FILE):
23
- # if the file is broken, just raise error
24
- with open(USER_INFO_FILE) as f:
25
- return json.load(f).get('cookies', None)
26
-
27
-
28
19
  class ProviderUI(AbstractProviderUi):
29
20
  def __init__(self, app: GuiApp):
30
21
  self._app = app
@@ -72,31 +63,13 @@ class LoginDialog(CookiesLoginDialog):
72
63
  provider._user = user
73
64
 
74
65
  async def user_from_cookies(self, cookies):
75
- if not cookies: # is None or empty
76
- raise InvalidCookies('empty cookies')
77
-
78
- uin = provider.api.get_uin_from_cookies(cookies)
79
- if uin is None:
80
- raise InvalidCookies("can't extract user info from cookies")
81
-
82
- provider.api.set_cookies(cookies)
83
- # try to extract current user
84
- try:
85
- user = await run_fn(provider.user_get, uin)
86
- except QQIOError:
87
- provider.api.set_cookies(None)
88
- raise InvalidCookies('get user info with cookies failed, expired cookies?')
89
- else:
66
+ user, err = await run_fn(provider.try_get_user_from_cookies, cookies)
67
+ if user:
90
68
  return user
69
+ raise InvalidCookies(err)
91
70
 
92
71
  def load_user_cookies(self):
93
72
  return read_cookies()
94
73
 
95
74
  def dump_user_cookies(self, user, cookies):
96
- js = {
97
- 'identifier': user.identifier,
98
- 'name': user.name,
99
- 'cookies': cookies
100
- }
101
- with open(USER_INFO_FILE, 'w') as f:
102
- json.dump(js, f, indent=2)
75
+ write_cookies(user, cookies)
@@ -10,6 +10,7 @@ from feeluown.library import (
10
10
  PlaylistModel,
11
11
  BriefPlaylistModel,
12
12
  UserModel,
13
+ VideoModel,
13
14
  )
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -143,6 +144,17 @@ class _BriefArtistSchema(Schema):
143
144
  return create_model(BriefArtistModel, data, ["mid"])
144
145
 
145
146
 
147
+ class SearchArtistSchema(_BriefArtistSchema):
148
+ pic_url = fields.Str(data_key="singerPic", required=True)
149
+
150
+ @post_load
151
+ def create_model(self, data, **kwargs):
152
+ data['hot_songs'] = []
153
+ data['description'] = ''
154
+ data['aliases'] = []
155
+ return create_model(ArtistModel, data, ["mid"])
156
+
157
+
146
158
  class _BriefAlbumSchema(Schema):
147
159
  identifier = fields.Int(data_key="albumID", required=True)
148
160
  mid = fields.Str(data_key="albumMID", required=True)
@@ -153,6 +165,20 @@ class _BriefAlbumSchema(Schema):
153
165
  return create_model(BriefAlbumModel, data, ["mid"])
154
166
 
155
167
 
168
+ class SearchAlbumSchema(_BriefAlbumSchema):
169
+ cover = fields.Str(data_key="albumPic", required=True)
170
+ released = fields.Str(data_key="publicTime", required=True)
171
+ song_count = fields.Int(required=True)
172
+ artists = fields.List(fields.Nested(_SongArtistSchema),
173
+ data_key="singer_list",
174
+ required=True)
175
+ @post_load
176
+ def create_model(self, data, **kwargs):
177
+ data['description'] = ''
178
+ data['songs'] = []
179
+ return create_model(AlbumModel, data, ['mid'])
180
+
181
+
156
182
  class _BriefPlaylistSchema(Schema):
157
183
  identifier = fields.Int(data_key="dissid", required=True)
158
184
  name = fields.Str(data_key="dissname", required=True)
@@ -164,6 +190,27 @@ class _BriefPlaylistSchema(Schema):
164
190
  return create_model(PlaylistModel, **data)
165
191
 
166
192
 
193
+ class PlaylistUserSchema(Schema):
194
+ identifier = fields.Str(data_key="creator_uin", required=True)
195
+ mid = fields.Str(data_key="encrypt_uin", required=True)
196
+ name = fields.Str(required=True)
197
+ avatar_url = fields.Str(required=True, data_key="avatarUrl")
198
+
199
+ @post_load
200
+ def create_model(self, data, **kwargs):
201
+ return create_model(UserModel, data, ['mid'])
202
+
203
+
204
+ class SearchPlaylistSchema(_BriefPlaylistSchema):
205
+ creator = fields.Nested("PlaylistUserSchema", required=True)
206
+ description = fields.Str(data_key="introduction", required=True)
207
+ play_count = fields.Int(data_key="listennum", required=True)
208
+
209
+ @post_load
210
+ def create_model(self, data, **kwargs):
211
+ return create_model(PlaylistModel, data)
212
+
213
+
167
214
  class QQArtistSchema(Schema):
168
215
  """歌手详情 Schema、歌曲歌手简要信息 Schema"""
169
216
 
@@ -294,3 +341,19 @@ class QQUserSchema(Schema):
294
341
  playlists=playlists,
295
342
  )
296
343
  return create_model(UserModel, data, ['mid', 'fav_pid', 'playlists'])
344
+
345
+
346
+ class SearchMVSchema(Schema):
347
+ # 使用 mv_id 字段的话,目前拿不到播放 url,用 v_id 比较合适
348
+ identifier = fields.Str(data_key="v_id", required=True)
349
+ title = fields.Str(data_key="mv_name", required=True)
350
+ artists = fields.List(fields.Nested("_SongArtistSchema"),
351
+ data_key="singer_list",
352
+ required=True)
353
+ duration = fields.Int(required=True)
354
+ cover = fields.Str(data_key="mv_pic_url", required=True)
355
+ play_count = fields.Int(required=True)
356
+
357
+ @post_load
358
+ def create_model(self, data, **kwargs):
359
+ return create_model(VideoModel, data)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
2
- Name: fuo-qqmusic
3
- Version: 1.0.4
1
+ Metadata-Version: 2.4
2
+ Name: fuo_qqmusic
3
+ Version: 1.0.6
4
4
  Summary: feeluown qqmusic plugin
5
5
  Home-page: https://github.com/feeluown/feeluown-qqmusic
6
6
  Author: Cosven
@@ -13,3 +13,13 @@ Classifier: Programming Language :: Python :: 3.7
13
13
  Classifier: Programming Language :: Python :: 3.8
14
14
  Classifier: Programming Language :: Python :: 3.9
15
15
  Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Dist: feeluown>=4.1.3
17
+ Requires-Dist: requests
18
+ Requires-Dist: marshmallow>=3.0
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: home-page
23
+ Dynamic: keywords
24
+ Dynamic: requires-dist
25
+ Dynamic: summary
@@ -4,6 +4,7 @@ fuo_qqmusic/__init__.py
4
4
  fuo_qqmusic/api.py
5
5
  fuo_qqmusic/consts.py
6
6
  fuo_qqmusic/excs.py
7
+ fuo_qqmusic/login.py
7
8
  fuo_qqmusic/provider.py
8
9
  fuo_qqmusic/provider_ui.py
9
10
  fuo_qqmusic/schemas.py
@@ -13,4 +14,5 @@ fuo_qqmusic.egg-info/dependency_links.txt
13
14
  fuo_qqmusic.egg-info/entry_points.txt
14
15
  fuo_qqmusic.egg-info/requires.txt
15
16
  fuo_qqmusic.egg-info/top_level.txt
16
- fuo_qqmusic/assets/icon.svg
17
+ fuo_qqmusic/assets/icon.svg
18
+ tests/test_provider.py
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='fuo_qqmusic',
8
- version='1.0.4',
8
+ version='1.0.6',
9
9
  description='feeluown qqmusic plugin',
10
10
  author='Cosven',
11
11
  author_email='yinshaowen241@gmail.com',
@@ -0,0 +1,26 @@
1
+ import json
2
+ import os
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from fuo_qqmusic import provider
8
+ from fuo_qqmusic.api import API
9
+
10
+
11
+ def _read_json_fixture(path):
12
+ path = os.path.join('data/fixtures', path)
13
+ with open(path, 'r', encoding='utf-8') as f:
14
+ data = json.load(f)
15
+ return data
16
+
17
+
18
+ @pytest.fixture
19
+ def album_3913679():
20
+ return _read_json_fixture('album_3913679.json')
21
+
22
+
23
+ def test_provider_album_get(album_3913679):
24
+ patch.object(API, 'album_detail', return_value=album_3913679)
25
+ album = provider.album_get('3913679')
26
+ assert album.identifier == '3913679'
File without changes