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

Files changed (22) hide show
  1. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/PKG-INFO +3 -3
  2. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/README.md +7 -0
  3. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/api.py +112 -0
  4. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/provider.py +66 -1
  5. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/provider_ui.py +3 -2
  6. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/schemas.py +1 -1
  7. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic.egg-info/PKG-INFO +3 -3
  8. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic.egg-info/SOURCES.txt +1 -0
  9. fuo_qqmusic-1.0.8/fuo_qqmusic.egg-info/requires.txt +3 -0
  10. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/setup.py +3 -3
  11. fuo_qqmusic-1.0.8/tests/test_api.py +56 -0
  12. fuo_qqmusic-1.0.6/fuo_qqmusic.egg-info/requires.txt +0 -3
  13. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/__init__.py +0 -0
  14. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/assets/icon.svg +0 -0
  15. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/consts.py +0 -0
  16. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/excs.py +0 -0
  17. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic/login.py +0 -0
  18. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic.egg-info/dependency_links.txt +0 -0
  19. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic.egg-info/entry_points.txt +0 -0
  20. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/fuo_qqmusic.egg-info/top_level.txt +0 -0
  21. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/setup.cfg +0 -0
  22. {fuo_qqmusic-1.0.6 → fuo_qqmusic-1.0.8}/tests/test_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuo_qqmusic
3
- Version: 1.0.6
3
+ Version: 1.0.8
4
4
  Summary: feeluown qqmusic plugin
5
5
  Home-page: https://github.com/feeluown/feeluown-qqmusic
6
6
  Author: Cosven
@@ -13,9 +13,9 @@ 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
16
+ Requires-Dist: feeluown>=4.1.13
17
17
  Requires-Dist: requests
18
- Requires-Dist: marshmallow>=3.0
18
+ Requires-Dist: marshmallow<4.0.0,>=3.0
19
19
  Dynamic: author
20
20
  Dynamic: author-email
21
21
  Dynamic: classifier
@@ -20,6 +20,13 @@ pip3 install fuo-qqmusic
20
20
  [操作示例](https://github.com/feeluown/feeluown-qqmusic/issues/6)。
21
21
 
22
22
  ## changelog
23
+ ### 1.0.8 (2025-07-03)
24
+ - 增强 web 登录可靠性
25
+ - 歌单支持添加/删除歌曲
26
+
27
+ ### 1.0.7 (2025-05-31)
28
+ - 支持音乐黑名单
29
+
23
30
  ### 1.0.6 (2025-05-08)
24
31
  - 支持每日推荐
25
32
  - 支持自动登录
@@ -8,6 +8,7 @@ import math
8
8
  import json
9
9
  import random
10
10
  import time
11
+ from enum import Enum
11
12
 
12
13
  import requests
13
14
  from .excs import QQIOError
@@ -286,6 +287,34 @@ class API(object):
286
287
  resp = requests.get(url, params=params)
287
288
  return resp.json()['data']
288
289
 
290
+ def playlist_remove_songs(self, playlist_id, song_id_list):
291
+ payload = {
292
+ 'req_0': {
293
+ 'method': 'DelSonglist',
294
+ 'module': 'music.musicasset.PlaylistDetailWrite',
295
+ 'param': {
296
+ 'dirId': playlist_id, # int
297
+ 'v_songInfo': [{'songId': int(song_id), 'songType': 0} for song_id in song_id_list]
298
+ }
299
+ }
300
+ }
301
+ js = self.rpc(payload)
302
+ return js['req_0']['code'] == 0
303
+
304
+ def playlist_add_songs(self, playlist_id, song_id_list):
305
+ payload = {
306
+ 'req_0': {
307
+ 'method': 'AddSonglist',
308
+ 'module': 'music.musicasset.PlaylistDetailWrite',
309
+ 'param': {
310
+ 'dirId': int(playlist_id), # int
311
+ 'v_songInfo': [{'songId': int(song_id), 'songType': 0} for song_id in song_id_list]
312
+ }
313
+ }
314
+ }
315
+ js = self.rpc(payload)
316
+ return js['req_0']['code'] == 0
317
+
289
318
  def playlist_detail(self, pid, offset=0, limit=50):
290
319
  url = api_base_url + '/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg'
291
320
  params = {
@@ -318,6 +347,7 @@ class API(object):
318
347
  resp = requests.get(url, params=params, headers=self._headers,
319
348
  cookies=self._cookies, timeout=self._timeout)
320
349
  js = resp.json()
350
+ logger.debug(f"user detail response: {js}")
321
351
  if js['code'] != 0:
322
352
  raise CodeShouldBe0(js)
323
353
  return js['data']
@@ -437,6 +467,7 @@ class API(object):
437
467
  def rpc(self, payload):
438
468
  if 'comm' not in payload:
439
469
  payload['comm'] = self.get_common_params()
470
+ logger.debug(f"rpc payload: {payload}")
440
471
  data_str = json.dumps(payload, ensure_ascii=False)
441
472
  params = {
442
473
  '_': int(round(time.time() * 1000)),
@@ -447,6 +478,7 @@ class API(object):
447
478
  resp = requests.get(url, params=params, headers=self._headers,
448
479
  cookies=self._cookies, timeout=self._timeout)
449
480
  js = resp.json()
481
+ logger.debug(f"rpc response json: {js}")
450
482
  CodeShouldBe0.check(js)
451
483
  return js
452
484
 
@@ -717,5 +749,85 @@ class API(object):
717
749
  return 'http://isure.stream.qqmusic.qq.com/{}'.format(midurlinfo[0]['purl'])
718
750
  return ''
719
751
 
752
+ class DislikeListType(Enum):
753
+ singer = 2
754
+ song = 3
755
+ _style_unsupported = 4 # TODO: 这是什么?似乎是不喜欢的风格列表,不确定,暂时不支持
756
+
757
+ def get_dislike_list(self, page=1, type_=DislikeListType.song, last_id=0):
758
+ payload = {
759
+ "req_0": {
760
+ "module": "music.feedback.FeedbackBlack",
761
+ "method": "GetDislikeList",
762
+ "param": {
763
+ "Cmd": type_.value,
764
+ "Page": page,
765
+ "SongLastid": last_id if type_ == API.DislikeListType.song else 0,
766
+ "SingersLastid": (
767
+ last_id if type_ == API.DislikeListType.singer else 0
768
+ ),
769
+ },
770
+ },
771
+ }
772
+ js = self.rpc(payload)
773
+ if type_ == API.DislikeListType.song:
774
+ return js["req_0"]["data"]["Songs"]
775
+ elif type_ == API.DislikeListType.singer:
776
+ return js["req_0"]["data"]["Singers"]
777
+ else:
778
+ raise QQIOError(f"Unknown dislike list type: {type_}")
779
+
780
+ def add_to_dislike_list(self, items, type_=DislikeListType.song):
781
+ req_param = {
782
+ "Singers": [],
783
+ "Songs": [],
784
+ "Styles": [],
785
+ "OnlyAdd": 1,
786
+ }
787
+ if type_ == API.DislikeListType.song:
788
+ req_param["Songs"] = items
789
+ elif type_ == API.DislikeListType.singer:
790
+ req_param["Singers"] = items
791
+ else:
792
+ raise QQIOError(f"Unknown dislike list type: {type_}")
793
+
794
+ payload = {
795
+ "req_0": {
796
+ "module": "music.feedback.FeedbackBlack",
797
+ "method": "AddDislike",
798
+ "param": req_param,
799
+ },
800
+ }
801
+ js = self.rpc(payload)
802
+ # Response example, {'code': 0, 'data': {'Retcode': 0, 'Msg': '', 'Token': ''}}
803
+ CodeShouldBe0.check(js['req_0'])
804
+ return js['req_0']['data']
805
+
806
+ def remove_from_dislike_list(self, items, type_=DislikeListType.song):
807
+ req_param = {
808
+ "Singers": [],
809
+ "Songs": [],
810
+ "Styles": [],
811
+ "OnlyAdd": 0,
812
+ }
813
+ if type_ == API.DislikeListType.song:
814
+ req_param["Songs"] = items
815
+ elif type_ == API.DislikeListType.singer:
816
+ req_param["Singers"] = items
817
+ else:
818
+ raise QQIOError(f"Unknown dislike list type: {type_}")
819
+
820
+ payload = {
821
+ "req_0": {
822
+ "module": "music.feedback.FeedbackBlack",
823
+ "method": "CancelDislike",
824
+ "param": req_param,
825
+ },
826
+ }
827
+ js = self.rpc(payload)
828
+ CodeShouldBe0.check(js)
829
+ CodeShouldBe0.check(js['req_0'])
830
+ return js['req_0']['data']
831
+
720
832
 
721
833
  api = API()
@@ -4,6 +4,7 @@ from feeluown.excs import ModelNotFound
4
4
  from feeluown.library import (
5
5
  AbstractProvider,
6
6
  BriefSongModel,
7
+ BriefPlaylistModel,
7
8
  PlaylistModel,
8
9
  Collection,
9
10
  CollectionType,
@@ -23,12 +24,17 @@ from feeluown.library import (
23
24
  SupportsPlaylistGet,
24
25
  SupportsPlaylistSongsReader,
25
26
  SupportsRecACollectionOfSongs,
27
+ SupportsCurrentUserDislikeSongsReader,
28
+ SupportsCurrentUserDislikeAddSong,
29
+ SupportsCurrentUserDislikeRemoveSong,
30
+ SupportsCurrentUserChanged,
26
31
  SimpleSearchResult,
27
32
  SearchType,
28
33
  ModelType,
29
34
  UserModel,
30
35
  )
31
36
  from feeluown.media import Media, Quality
37
+ from feeluown.utils.dispatch import Signal
32
38
  from feeluown.utils.reader import create_reader, SequentialReader
33
39
  from .api import API
34
40
  from .login import read_cookies
@@ -53,6 +59,9 @@ class Supports(
53
59
  SupportsPlaylistSongsReader,
54
60
  SupportsRecACollectionOfSongs,
55
61
  SupportsAlbumSongsReader,
62
+ SupportsCurrentUserDislikeSongsReader,
63
+ SupportsCurrentUserDislikeAddSong,
64
+ SupportsCurrentUserDislikeRemoveSong,
56
65
  Protocol,
57
66
  ):
58
67
  pass
@@ -70,6 +79,7 @@ class QQProvider(AbstractProvider, ProviderV2):
70
79
  def __init__(self):
71
80
  super().__init__()
72
81
  self.api = API()
82
+ self.current_user_changed = Signal()
73
83
 
74
84
  def _(self) -> Supports:
75
85
  return self
@@ -89,6 +99,7 @@ class QQProvider(AbstractProvider, ProviderV2):
89
99
  self.auth(user)
90
100
  else:
91
101
  logger.info(f'Auto login failed: {err}')
102
+ self.current_user_changed.emit(user)
92
103
 
93
104
  def try_get_user_from_cookies(self, cookies) -> Tuple[Optional[UserModel], str]:
94
105
  if not cookies: # is None or empty
@@ -270,6 +281,22 @@ class QQProvider(AbstractProvider, ProviderV2):
270
281
  data = self.api.playlist_detail(int(identifier), limit=1000)
271
282
  return _deserialize(data, QQPlaylistSchema)
272
283
 
284
+ def playlist_add_song(self, playlist, song):
285
+ # FIXME: 目前 playlist 相关接口用的都是 diss 结构体,而这里需要一个 dirid。
286
+ # 平台方也提供了 dir 相关的接口,我大胆猜测,diss 是一套老接口。
287
+ playlist._cache.pop("songs", None)
288
+ dirid = self._get_dirid_by_playlist_id(playlist.identifier)
289
+ return self.api.playlist_add_songs(dirid, [song.identifier])
290
+
291
+ def playlist_remove_song(self, playlist, song):
292
+ playlist._cache.pop("songs", None)
293
+ dirid = self._get_dirid_by_playlist_id(playlist.identifier)
294
+ return self.api.playlist_remove_songs(dirid, [song.identifier])
295
+
296
+ def _get_dirid_by_playlist_id(self, playlist_id):
297
+ data = self.api.playlist_detail(int(playlist_id), limit=1)
298
+ return data["dirid"]
299
+
273
300
  def playlist_create_songs_rd(self, playlist):
274
301
  songs = self._model_cache_get_or_fetch(playlist, "songs")
275
302
  return create_reader(songs)
@@ -371,7 +398,13 @@ class QQProvider(AbstractProvider, ProviderV2):
371
398
  if user is None:
372
399
  return []
373
400
  playlists = self._model_cache_get_or_fetch(user, "playlists")
374
- return playlists
401
+ fav_pid = self._model_cache_get_or_fetch(user, "fav_pid")
402
+ my_love = BriefPlaylistModel(
403
+ source=SOURCE,
404
+ identifier=str(fav_pid),
405
+ name='我喜欢'
406
+ )
407
+ return [my_love] + playlists
375
408
 
376
409
  def current_user_fav_create_songs_rd(self):
377
410
  user = self.get_current_user()
@@ -405,6 +438,9 @@ class QQProvider(AbstractProvider, ProviderV2):
405
438
  return create_reader([])
406
439
  mid = self._model_cache_get_or_fetch(user, "mid")
407
440
  playlists = self.api.user_favorite_playlists(user.identifier, mid)
441
+ # HACK: 给 playlist 加一个 disstid 字段,这样可以兼容 QQPlaylistSchema
442
+ for playlist in playlists:
443
+ playlist["disstid"] = playlist["dissid"]
408
444
  return [_deserialize(playlist, QQPlaylistSchema) for playlist in playlists]
409
445
 
410
446
  def has_current_user(self):
@@ -417,6 +453,35 @@ class QQProvider(AbstractProvider, ProviderV2):
417
453
  data_songs = self.api.song_similar(int(song.identifier))
418
454
  return [_deserialize(data_song, QQSongSchema) for data_song in data_songs]
419
455
 
456
+ def current_user_dislike_create_songs_rd(self):
457
+ user = self.get_current_user()
458
+ if user is None:
459
+ return create_reader([])
460
+ # FIXME: 如果用户的黑名单歌曲数量较多的话,这样处理则是不够的
461
+ items = self.api.get_dislike_list(1, API.DislikeListType.song, 0)
462
+ songs = []
463
+ for item in items:
464
+ name = item['Name']
465
+ title, artists_name = name.split(' - ')
466
+ song = BriefSongModel(
467
+ source=SOURCE,
468
+ identifier=item['ID'],
469
+ title=title,
470
+ artists_name=artists_name,
471
+ )
472
+ songs.append(song)
473
+ return create_reader(songs)
474
+
475
+ def current_user_dislike_add_song(self, song):
476
+ items = [{'ID': song.identifier}]
477
+ js = self.api.add_to_dislike_list(items, API.DislikeListType.song)
478
+ return js.get('Retcode') == 0
479
+
480
+ def current_user_dislike_remove_song(self, song):
481
+ items = [{'ID': song.identifier}]
482
+ js = self.api.remove_from_dislike_list(items, API.DislikeListType.song)
483
+ return js.get('Retcode') == 0
484
+
420
485
 
421
486
  def _deserialize(data, schema_cls):
422
487
  schema = schema_cls()
@@ -39,8 +39,9 @@ class ProviderUI(AbstractProviderUi):
39
39
  #
40
40
  # - keys: ['skey']
41
41
  url = os.getenv('FUO_QQMUSIC_LOGIN_URL', 'https://y.qq.com')
42
- keys = os.getenv('FUO_QQMUSIC_LOGIN_COOKIE_KEYS', 'qqmusic_key').split(',')
43
- self._dialog = LoginDialog(url, keys)
42
+ keys_str = os.getenv('FUO_QQMUSIC_LOGIN_COOKIE_KEYS',
43
+ 'qqmusic_key,wxuin|qqmusic_key,uin')
44
+ self._dialog = LoginDialog(url, [keys.split(',') for keys in keys_str.split('|')])
44
45
  self._dialog.login_succeed.connect(self.on_login_succeed)
45
46
  self._dialog.show()
46
47
  self._dialog.autologin()
@@ -271,7 +271,7 @@ class QQAlbumSchema(Schema):
271
271
 
272
272
 
273
273
  class QQPlaylistSchema(Schema):
274
- identifier = fields.Int(required=True, data_key="dissid")
274
+ identifier = fields.Int(required=True, data_key="disstid")
275
275
  name = fields.Str(required=True, data_key="dissname")
276
276
  cover = fields.Str(required=True, data_key="logo")
277
277
  # songs field maybe null, though it can't be null in model
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuo_qqmusic
3
- Version: 1.0.6
3
+ Version: 1.0.8
4
4
  Summary: feeluown qqmusic plugin
5
5
  Home-page: https://github.com/feeluown/feeluown-qqmusic
6
6
  Author: Cosven
@@ -13,9 +13,9 @@ 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
16
+ Requires-Dist: feeluown>=4.1.13
17
17
  Requires-Dist: requests
18
- Requires-Dist: marshmallow>=3.0
18
+ Requires-Dist: marshmallow<4.0.0,>=3.0
19
19
  Dynamic: author
20
20
  Dynamic: author-email
21
21
  Dynamic: classifier
@@ -15,4 +15,5 @@ fuo_qqmusic.egg-info/entry_points.txt
15
15
  fuo_qqmusic.egg-info/requires.txt
16
16
  fuo_qqmusic.egg-info/top_level.txt
17
17
  fuo_qqmusic/assets/icon.svg
18
+ tests/test_api.py
18
19
  tests/test_provider.py
@@ -0,0 +1,3 @@
1
+ feeluown>=4.1.13
2
+ requests
3
+ marshmallow<4.0.0,>=3.0
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='fuo_qqmusic',
8
- version='1.0.6',
8
+ version='1.0.8',
9
9
  description='feeluown qqmusic plugin',
10
10
  author='Cosven',
11
11
  author_email='yinshaowen241@gmail.com',
@@ -27,9 +27,9 @@ setup(
27
27
  'Programming Language :: Python :: 3 :: Only',
28
28
  ],
29
29
  install_requires=[
30
- 'feeluown>=4.1.3',
30
+ 'feeluown>=4.1.13',
31
31
  'requests',
32
- 'marshmallow>=3.0'
32
+ 'marshmallow>=3.0,<4.0.0'
33
33
  ],
34
34
  entry_points={
35
35
  'fuo.plugins_v1': [
@@ -0,0 +1,56 @@
1
+ from fuo_qqmusic.api import API
2
+ import pytest
3
+
4
+
5
+ def parse_cookie_to_dict(cookie_str):
6
+ # 初始化结果字典
7
+ result = {}
8
+
9
+ # 按分号分割字符串,得到每个键值对
10
+ pairs = cookie_str.split(";")
11
+
12
+ for pair in pairs:
13
+ # 去除两端空格
14
+ pair = pair.strip()
15
+
16
+ # 按等号分割键和值
17
+ if "=" in pair:
18
+ key, value = pair.split("=", 1)
19
+ key = key.strip()
20
+ value = value.strip()
21
+
22
+ # 如果值为空字符串,则设置为 None
23
+ if value == "":
24
+ value = None
25
+
26
+ # 存入字典
27
+ result[key] = value
28
+
29
+ return result
30
+
31
+
32
+ cookie_str = "Your cookie string here"
33
+
34
+
35
+ @pytest.mark.skip(reason="need valid cookies")
36
+ def test_api():
37
+ api = API()
38
+ api.set_cookies(parse_cookie_to_dict(cookie_str))
39
+ # You can also use the following code to load cookies
40
+ # from fuo_qqmusic.provider import provider
41
+ # provider.auto_login()
42
+ # api = provider.api
43
+ items = [
44
+ {
45
+ "ID": "238159921",
46
+ "Name": "无人区-Vacuum Track#ADD8E6- - 米缐p.",
47
+ "IdType": 0,
48
+ }
49
+ ]
50
+ print(api.add_to_dislike_list(items, type_=API.DislikeListType.song))
51
+ print(api.get_dislike_list(type_=API.DislikeListType.song))
52
+ print(api.remove_from_dislike_list(items, type_=API.DislikeListType.song))
53
+
54
+
55
+ if __name__ == "__main__":
56
+ test_api()
@@ -1,3 +0,0 @@
1
- feeluown>=4.1.3
2
- requests
3
- marshmallow>=3.0
File without changes