fuo-qqmusic 1.0.5__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.

Potentially problematic release.


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

@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-147 -173.29999999999998 470 492.29999999999995" width="256.0px" height="256.0px">
3
+ <linearGradient id="a" gradientTransform="rotate(-90 1397 232)" gradientUnits="userSpaceOnUse" x1="1310" x2="1780" y1="-1077" y2="-1077">
4
+ <stop offset="0" stop-color="#fbbe0a" />
5
+ <stop offset="1" stop-color="#feda24" />
6
+ </linearGradient>
7
+ <circle cx="88" cy="84" fill="url(#a)" r="235" />
8
+ <path d="M123.8 104c-5.9-8.3-11.5-16.1-17.1-23.8C85.2 50.4 63.6 20.6 42-9.1 28.3-28 14.7-46.9.8-65.6-3.4-71.2-3.9-77.1-2-83.5c3.9-13.3 13.2-22.5 24.2-30.1 20.1-13.9 42.8-21 66.6-25.2 20.7-3.6 41.2-8 59.3-19.4 4.9-3.1 9-7.3 13.5-11 1.2-1 2.3-2.1 4.6-4.1 1.5 7.3 3 13.4 4 19.5 3 18 1.9 35.5-6.2 52.1-11 22.4-29 36.4-52.6 43.6C97-53.5 82.1-52.4 67-52.5c-1.1 0-2.2.3-4.1.5 3.7 6.5 7 12.6 10.6 18.5 14.5 23.7 29 47.4 43.4 71.2l47.4 78.6c4.1 6.8 8.4 13.6 12.4 20.5 9.3 16 16.1 32.7 14.8 51.9-1.3 18.8-8.1 35.2-19.8 49.6-19.1 23.5-43.9 37.1-73.5 42.4-27.9 4.9-54.5 1.8-79.2-12.3-27.8-15.8-45.6-44.5-41.4-78.7 2.7-22.5 13.9-41.1 30.4-56.4 17.6-16.2 38.5-26.1 61.8-30.6 17.2-3.4 34.4-3.2 51.4 1.4.5.1 1.1-.1 2.6-.1z"
9
+ fill="#0daf52" />
10
+ </svg>
fuo_qqmusic/consts.py ADDED
@@ -0,0 +1,6 @@
1
+ from feeluown.consts import DATA_DIR
2
+
3
+
4
+ COOKIES_FILE = DATA_DIR + '/qqmusic_cookies.json'
5
+ USER_PW_FILE = DATA_DIR + '/qm_user_pw.json'
6
+ USERS_INFO_FILE = DATA_DIR + '/qm_users_info.json'
fuo_qqmusic/excs.py ADDED
@@ -0,0 +1,6 @@
1
+ from feeluown.excs import ProviderIOError
2
+
3
+
4
+ class QQIOError(ProviderIOError):
5
+ def __init__(self, message):
6
+ super().__init__(message, provider='qq')
@@ -0,0 +1,447 @@
1
+ import logging
2
+ from typing import List, Optional, Protocol
3
+ from feeluown.excs import ModelNotFound
4
+ from feeluown.library import (
5
+ AbstractProvider,
6
+ BriefSongModel,
7
+ PlaylistModel,
8
+ Collection,
9
+ CollectionType,
10
+ ProviderV2,
11
+ ProviderFlags as PF,
12
+ SupportsSongGet,
13
+ SupportsSongMultiQuality,
14
+ SupportsSongMV,
15
+ VideoModel,
16
+ LyricModel,
17
+ SupportsCurrentUser,
18
+ SupportsVideoGet,
19
+ SupportsSongLyric,
20
+ SupportsAlbumGet,
21
+ SupportsAlbumSongsReader,
22
+ SupportsArtistGet,
23
+ SupportsPlaylistGet,
24
+ SupportsPlaylistSongsReader,
25
+ SupportsRecACollectionOfSongs,
26
+ SimpleSearchResult,
27
+ SearchType,
28
+ ModelType,
29
+ )
30
+ from feeluown.media import Media, Quality
31
+ from feeluown.utils.reader import create_reader, SequentialReader
32
+ from .api import API
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+ UNFETCHED_MEDIA = object()
37
+ SOURCE = "qqmusic"
38
+
39
+
40
+ class Supports(
41
+ SupportsSongGet,
42
+ SupportsSongMultiQuality,
43
+ SupportsSongMV,
44
+ SupportsCurrentUser,
45
+ SupportsVideoGet,
46
+ SupportsSongLyric,
47
+ SupportsAlbumGet,
48
+ SupportsArtistGet,
49
+ SupportsPlaylistGet,
50
+ SupportsPlaylistSongsReader,
51
+ SupportsRecACollectionOfSongs,
52
+ SupportsAlbumSongsReader,
53
+ Protocol,
54
+ ):
55
+ pass
56
+
57
+
58
+ class QQProvider(AbstractProvider, ProviderV2):
59
+ class meta:
60
+ identifier = "qqmusic"
61
+ name = "QQ 音乐"
62
+ flags = {
63
+ ModelType.song: PF.similar,
64
+ ModelType.none: PF.current_user,
65
+ }
66
+
67
+ def __init__(self):
68
+ super().__init__()
69
+ self.api = API()
70
+
71
+ def _(self) -> Supports:
72
+ return self
73
+
74
+ @property
75
+ def identifier(self):
76
+ return "qqmusic"
77
+
78
+ @property
79
+ def name(self):
80
+ return "QQ 音乐"
81
+
82
+ def use_model_v2(self, mtype):
83
+ return mtype in (
84
+ ModelType.song,
85
+ ModelType.album,
86
+ ModelType.artist,
87
+ ModelType.playlist,
88
+ )
89
+
90
+ def song_get(self, identifier):
91
+ data = self.api.song_detail(identifier)
92
+ return _deserialize(data, QQSongSchema)
93
+
94
+ def song_get_mv(self, song):
95
+ mv_id = self._model_cache_get_or_fetch(song, "mv_id")
96
+ if mv_id == 0:
97
+ return None
98
+ video = self.video_get(mv_id)
99
+ # mv 的名字是空的
100
+ video.title = song.title
101
+ return video
102
+
103
+ def song_get_lyric(self, song):
104
+ mid = self._model_cache_get_or_fetch(song, "mid")
105
+ content = self.api.get_lyric_by_songmid(mid)
106
+ return LyricModel(identifier=mid, source=SOURCE, content=content)
107
+
108
+ def video_get(self, identifier):
109
+ data = self.api.get_mv(identifier)
110
+ if not data:
111
+ raise ModelNotFound(f"mv:{identifier} not found")
112
+ fhd = hd = sd = ld = None
113
+ for file in data["mp4"]:
114
+ if not file["url"]:
115
+ continue
116
+ file_type = file["filetype"]
117
+ url = file["freeflow_url"][0]
118
+ if file_type == 40:
119
+ fhd = url
120
+ elif file_type == 30:
121
+ hd = url
122
+ elif file_type == 20:
123
+ sd = url
124
+ elif file_type == 10:
125
+ ld = url
126
+ elif file_type == 0:
127
+ pass
128
+ else:
129
+ logger.warning("There exists another quality:%s mv.", str(file_type))
130
+ q_url_mapping = dict(fhd=fhd, hd=hd, sd=sd, ld=ld)
131
+ video = VideoModel(
132
+ identifier=identifier,
133
+ source=SOURCE,
134
+ title="未知名字",
135
+ artists=[],
136
+ duration=1,
137
+ cover="",
138
+ )
139
+ video.cache_set("q_url_mapping", q_url_mapping)
140
+ return video
141
+
142
+ def video_get_media(self, video, quality):
143
+ q_media_mapping = self._model_cache_get_or_fetch(video, "q_url_mapping")
144
+ return Media(q_media_mapping[quality.value])
145
+
146
+ def video_list_quality(self, video):
147
+ q_media_mapping = self._model_cache_get_or_fetch(video, "q_url_mapping")
148
+ return [Quality.Video(k) for k, v in q_media_mapping.items() if v]
149
+
150
+ def song_list_quality(self, song) -> List[Quality.Audio]:
151
+ """List all possible qualities
152
+
153
+ Please ensure all the qualities are valid. `song_get_media(song, quality)`
154
+ must not return None with a valid quality.
155
+ """
156
+ return list(self._song_get_q_media_mapping(song))
157
+
158
+ def song_get_media(self, song, quality: Quality.Audio) -> Optional[Media]:
159
+ """Get song's media by a specified quality
160
+
161
+ :return: when quality is invalid, return None
162
+ """
163
+ q_media_mapping = self._song_get_q_media_mapping(song)
164
+ quality_suffix = self._model_cache_get_or_fetch(song, "quality_suffix")
165
+ mid = self._model_cache_get_or_fetch(song, "mid")
166
+ media_id = self._model_cache_get_or_fetch(song, "media_id")
167
+ media = q_media_mapping.get(quality)
168
+ if media is UNFETCHED_MEDIA:
169
+ for q, t, b, s in quality_suffix:
170
+ if quality == Quality.Audio(q):
171
+ url = self.api.get_song_url_v2(mid, media_id, t)
172
+ if url:
173
+ media = Media(url, bitrate=b, format=s)
174
+ q_media_mapping[quality] = media
175
+ else:
176
+ media = None
177
+ q_media_mapping[quality] = None
178
+ break
179
+ else:
180
+ media = None
181
+ return media
182
+
183
+ def _song_get_q_media_mapping(self, song):
184
+ q_media_mapping, exists = song.cache_get("q_media_mapping")
185
+ if exists is True:
186
+ return q_media_mapping
187
+ quality_suffix = self._model_cache_get_or_fetch(song, "quality_suffix")
188
+ mid = self._model_cache_get_or_fetch(song, "mid")
189
+ media_id = self._model_cache_get_or_fetch(song, "media_id")
190
+ q_media_mapping = {}
191
+ # 注:self.quality_suffix 这里可能会触发一次网络请求
192
+ for idx, (q, t, b, s) in enumerate(quality_suffix):
193
+ url = self.api.get_song_url_v2(mid, media_id, t)
194
+ if url:
195
+ q_media_mapping[Quality.Audio(q)] = Media(url, bitrate=b, format=s)
196
+ # 一般来说,高品质有权限 低品质也会有权限,减少网络请求。
197
+ # 这里把值设置为 UNFETCHED_MEDIA,作为一个标记。
198
+ for i in range(idx + 1, len(quality_suffix)):
199
+ q_media_mapping[
200
+ Quality.Audio(quality_suffix[i][0])
201
+ ] = UNFETCHED_MEDIA
202
+ break
203
+ song.cache_set("q_media_mapping", q_media_mapping)
204
+ return q_media_mapping
205
+
206
+ def artist_get(self, identifier):
207
+ data_mid = self.api.artist_songs(int(identifier), 1, 0)["singerMid"]
208
+ data_artist = self.api.artist_detail(data_mid)
209
+ artist = _deserialize(data_artist, QQArtistSchema)
210
+ return artist
211
+
212
+ def artist_create_songs_rd(self, artist):
213
+ return create_g(
214
+ self.api.artist_songs, int(artist.identifier), _ArtistSongSchema
215
+ )
216
+
217
+ def artist_create_albums_rd(self, artist):
218
+ return create_g(
219
+ self.api.artist_albums, int(artist.identifier), _BriefAlbumSchema
220
+ )
221
+
222
+ def album_get(self, identifier):
223
+ data_album = self.api.album_detail(int(identifier))
224
+ if data_album is None:
225
+ raise ModelNotFound
226
+ album = _deserialize(data_album, QQAlbumSchema)
227
+ return album
228
+
229
+ def album_create_songs_rd(self, album):
230
+ album = self.album_get(album.identifier)
231
+ return create_reader(album.songs)
232
+
233
+ def user_get(self, identifier):
234
+ data = self.api.user_detail(identifier)
235
+ data["creator"]["fav_pid"] = data["mymusic"][0]["id"]
236
+ # 假设使用微信登陆,从网页拿到 cookie,cookie 里面的 uin 是正确的,
237
+ # 而这个接口返回的 uin 则可能是 0,因此手动重置一下。
238
+ data["creator"]["uin"] = identifier
239
+ return _deserialize(data, QQUserSchema)
240
+
241
+ def playlist_get(self, identifier):
242
+ data = self.api.playlist_detail(int(identifier), limit=1000)
243
+ return _deserialize(data, QQPlaylistSchema)
244
+
245
+ def playlist_create_songs_rd(self, playlist):
246
+ songs = self._model_cache_get_or_fetch(playlist, "songs")
247
+ return create_reader(songs)
248
+
249
+ def __rec_hot_playlists(self):
250
+ user = self.get_current_user()
251
+ if user is None:
252
+ return []
253
+ # pids = self.api.get_recommend_playlists_ids()
254
+ # rec_playlists = [QQPlaylistModel.get(pid) for pid in pids]
255
+ playlists = self.api.recommend_playlists()
256
+ for pl in playlists:
257
+ pl["dissid"] = pl["content_id"]
258
+ pl["dissname"] = pl["title"]
259
+ pl["logo"] = pl["cover"]
260
+ return [_deserialize(playlist, QQPlaylistSchema) for playlist in playlists]
261
+
262
+ def rec_list_daily_playlists(self):
263
+ # TODO: cache API result
264
+ feed = self.api.get_recommend_feed()
265
+ shelf = None
266
+ for shelf_ in feed['v_shelf']:
267
+ # I guess 10046 means 'song'.
268
+ if shelf_['extra_info'].get('moduleID', '').startswith('playlist'):
269
+ shelf = shelf_
270
+ break
271
+ if shelf is None:
272
+ return []
273
+ playlists = []
274
+ for batch in shelf['v_niche']:
275
+ for card in batch['v_card']:
276
+ if card['jumptype'] == 10014: # 10014->playlist
277
+ playlists.append(
278
+ PlaylistModel(identifier=str(card['id']),
279
+ source=SOURCE,
280
+ name=card['title'],
281
+ cover=card['cover'],
282
+ description=card['miscellany']['rcmdtemplate'],
283
+ play_count=card['cnt'])
284
+ )
285
+ return playlists
286
+
287
+ def rec_a_collection_of_songs(self):
288
+ # TODO: cache API result
289
+ feed = self.api.get_recommend_feed()
290
+ shelf = None
291
+ for shelf_ in feed['v_shelf']:
292
+ # I guess 10046 means 'song'.
293
+ if int(shelf_['miscellany'].get('jumptype', 0)) == 10046:
294
+ shelf = shelf_
295
+ break
296
+ if shelf is None:
297
+ return Collection(name='',
298
+ type_=CollectionType.only_songs,
299
+ models=[],
300
+ description='')
301
+ title = shelf['title_content'] or shelf['title_template']
302
+ song_ids = []
303
+ for batch in shelf['v_niche']:
304
+ for card in batch['v_card']:
305
+ if card['jumptype'] == 10046:
306
+ song_id = int(card['id'])
307
+ if song_id not in song_ids:
308
+ song_ids.append(song_id)
309
+
310
+ tracks = self.api.batch_song_details(song_ids)
311
+ return Collection(name=title,
312
+ type_=CollectionType.only_songs,
313
+ models=[_deserialize(track, QQSongSchema) for track in tracks],
314
+ description='')
315
+
316
+ def current_user_get_radio_songs(self):
317
+ songs_data = self.api.get_radio_music()
318
+ return [_deserialize(s, QQSongSchema) for s in songs_data]
319
+
320
+ def current_user_list_playlists(self):
321
+ user = self.get_current_user()
322
+ if user is None:
323
+ return []
324
+ playlists = self._model_cache_get_or_fetch(user, "playlists")
325
+ return playlists
326
+
327
+ def current_user_fav_create_songs_rd(self):
328
+ user = self.get_current_user()
329
+ if user is None:
330
+ return create_reader([])
331
+ fav_pid = self._model_cache_get_or_fetch(user, "fav_pid")
332
+ playlist = self.playlist_get(fav_pid)
333
+ reader = create_reader(self.playlist_create_songs_rd(playlist))
334
+ return reader.readall()
335
+
336
+ def current_user_fav_create_albums_rd(self):
337
+ user = self.get_current_user()
338
+ if user is None:
339
+ return create_reader([])
340
+ # TODO: fetch more if total count > 100
341
+ albums = self.api.user_favorite_albums(user.identifier)
342
+ return [_deserialize(album, _UserAlbumSchema) for album in albums]
343
+
344
+ def current_user_fav_create_artists_rd(self):
345
+ user = self.get_current_user()
346
+ if user is None:
347
+ return create_reader([])
348
+ # TODO: fetch more if total count > 100
349
+ mid = self._model_cache_get_or_fetch(user, "mid")
350
+ artists = self.api.user_favorite_artists(user.identifier, mid)
351
+ return [_deserialize(artist, _UserArtistSchema) for artist in artists]
352
+
353
+ def current_user_fav_create_playlists_rd(self):
354
+ user = self.get_current_user()
355
+ if user is None:
356
+ return create_reader([])
357
+ mid = self._model_cache_get_or_fetch(user, "mid")
358
+ playlists = self.api.user_favorite_playlists(user.identifier, mid)
359
+ return [_deserialize(playlist, QQPlaylistSchema) for playlist in playlists]
360
+
361
+ def has_current_user(self):
362
+ return self._user is not None
363
+
364
+ def get_current_user(self):
365
+ return self._user
366
+
367
+ def song_list_similar(self, song):
368
+ data_songs = self.api.song_similar(int(song.identifier))
369
+ return [_deserialize(data_song, QQSongSchema) for data_song in data_songs]
370
+
371
+
372
+ def _deserialize(data, schema_cls):
373
+ schema = schema_cls()
374
+ obj = schema.load(data)
375
+ return obj
376
+
377
+
378
+ def create_g(func, identifier, schema):
379
+ data = func(identifier, page=1)
380
+ total = int(data["totalNum"] if schema == _ArtistSongSchema else data["total"])
381
+
382
+ def g():
383
+ nonlocal data
384
+ if data is None:
385
+ yield from ()
386
+ else:
387
+ page = 1
388
+ while data["songList"] if schema == _ArtistSongSchema else data["list"]:
389
+ obj_data_list = (
390
+ data["songList"] if schema == _ArtistSongSchema else data["list"]
391
+ )
392
+ for obj_data in obj_data_list:
393
+ obj = _deserialize(obj_data, schema)
394
+ yield obj
395
+ page += 1
396
+ data = func(identifier, page)
397
+
398
+ return SequentialReader(g(), total)
399
+
400
+
401
+ def search(keyword, **kwargs):
402
+ type_ = SearchType.parse(kwargs["type_"])
403
+ type_type_map = {
404
+ SearchType.so: 0,
405
+ SearchType.ar: 1,
406
+ SearchType.al: 2,
407
+ SearchType.pl: 3,
408
+ SearchType.vi: 4,
409
+ }
410
+ data = provider.api.search(keyword, type_=type_type_map[type_])
411
+ if type_ == SearchType.so:
412
+ songs = [_deserialize(song, QQSongSchema) for song in data]
413
+ return SimpleSearchResult(q=keyword, songs=songs)
414
+ if type_ == SearchType.ar:
415
+ artists = [_deserialize(artist, SearchArtistSchema) for artist in data]
416
+ return SimpleSearchResult(q=keyword, artists=artists)
417
+ elif type_ == SearchType.al:
418
+ albums = [_deserialize(album, SearchAlbumSchema) for album in data]
419
+ return SimpleSearchResult(q=keyword, albums=albums)
420
+ elif type_ == SearchType.pl:
421
+ playlists = [_deserialize(playlist, SearchPlaylistSchema) for playlist in data]
422
+ return SimpleSearchResult(q=keyword, playlists=playlists)
423
+ elif type_ == SearchType.vi:
424
+ models = [_deserialize(model, SearchMVSchema) for model in data]
425
+ return SimpleSearchResult(q=keyword, videos=models)
426
+
427
+
428
+ provider = QQProvider()
429
+ provider.search = search
430
+
431
+
432
+ from .schemas import ( # noqa
433
+ QQSongSchema,
434
+ QQArtistSchema,
435
+ _ArtistSongSchema,
436
+ _BriefAlbumSchema,
437
+ _UserArtistSchema,
438
+ _BriefArtistSchema,
439
+ QQAlbumSchema,
440
+ QQPlaylistSchema,
441
+ QQUserSchema,
442
+ _UserAlbumSchema,
443
+ SearchAlbumSchema,
444
+ SearchArtistSchema,
445
+ SearchPlaylistSchema,
446
+ SearchMVSchema,
447
+ ) # noqa
@@ -0,0 +1,102 @@
1
+ import json
2
+ import logging
3
+ import os
4
+
5
+ from feeluown.utils.dispatch import Signal
6
+ from feeluown.utils.aio import run_fn
7
+ from feeluown.consts import DATA_DIR
8
+ from feeluown.gui.widgets.login import CookiesLoginDialog, InvalidCookies
9
+ from feeluown.gui.provider_ui import AbstractProviderUi
10
+ from feeluown.app.gui_app import GuiApp
11
+
12
+ from .provider import provider
13
+ from .excs import QQIOError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
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
+ class ProviderUI(AbstractProviderUi):
29
+ def __init__(self, app: GuiApp):
30
+ self._app = app
31
+ self._login_event = Signal()
32
+
33
+ @property
34
+ def provider(self):
35
+ return provider
36
+
37
+ def get_colorful_svg(self) -> str:
38
+ return os.path.join(os.path.dirname(__file__), 'assets', 'icon.svg')
39
+
40
+ def login_or_go_home(self):
41
+ if provider._user is None:
42
+ # According to #14, we have two ways to login:
43
+ # 1. the default way, as the code shows
44
+ # 2. a way for VIP user(maybe):
45
+ # - url: https://xui.ptlogin2.qq.com/cgi-bin/xlogin?appid=1006102
46
+ # &daid=384&low_login=1&pt_no_auth=1
47
+ # &s_url=https://y.qq.com/vip/daren_recruit/apply.html&style=40
48
+ #
49
+ # - keys: ['skey']
50
+ url = os.getenv('FUO_QQMUSIC_LOGIN_URL', 'https://y.qq.com')
51
+ keys = os.getenv('FUO_QQMUSIC_LOGIN_COOKIE_KEYS', 'qqmusic_key').split(',')
52
+ self._dialog = LoginDialog(url, keys)
53
+ self._dialog.login_succeed.connect(self.on_login_succeed)
54
+ self._dialog.show()
55
+ self._dialog.autologin()
56
+ else:
57
+ logger.info('already logged in')
58
+ self.login_event.emit(self, 2)
59
+
60
+ @property
61
+ def login_event(self):
62
+ return self._login_event
63
+
64
+ def on_login_succeed(self):
65
+ del self._dialog
66
+ self.login_event.emit(self, 1)
67
+
68
+
69
+ class LoginDialog(CookiesLoginDialog):
70
+
71
+ def setup_user(self, user):
72
+ provider._user = user
73
+
74
+ 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:
90
+ return user
91
+
92
+ def load_user_cookies(self):
93
+ return read_cookies()
94
+
95
+ 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)