musicdl 2.1.11__py3-none-any.whl → 2.7.3__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.
Files changed (59) hide show
  1. musicdl/__init__.py +5 -5
  2. musicdl/modules/__init__.py +10 -3
  3. musicdl/modules/common/__init__.py +2 -0
  4. musicdl/modules/common/gdstudio.py +204 -0
  5. musicdl/modules/js/__init__.py +1 -0
  6. musicdl/modules/js/youtube/__init__.py +2 -0
  7. musicdl/modules/js/youtube/botguard.js +1 -0
  8. musicdl/modules/js/youtube/jsinterp.py +902 -0
  9. musicdl/modules/js/youtube/runner.js +2 -0
  10. musicdl/modules/sources/__init__.py +41 -10
  11. musicdl/modules/sources/apple.py +207 -0
  12. musicdl/modules/sources/base.py +256 -28
  13. musicdl/modules/sources/bilibili.py +118 -0
  14. musicdl/modules/sources/buguyy.py +148 -0
  15. musicdl/modules/sources/fangpi.py +153 -0
  16. musicdl/modules/sources/fivesing.py +108 -0
  17. musicdl/modules/sources/gequbao.py +148 -0
  18. musicdl/modules/sources/jamendo.py +108 -0
  19. musicdl/modules/sources/joox.py +104 -68
  20. musicdl/modules/sources/kugou.py +129 -76
  21. musicdl/modules/sources/kuwo.py +188 -68
  22. musicdl/modules/sources/lizhi.py +107 -0
  23. musicdl/modules/sources/migu.py +172 -66
  24. musicdl/modules/sources/mitu.py +140 -0
  25. musicdl/modules/sources/mp3juice.py +264 -0
  26. musicdl/modules/sources/netease.py +163 -115
  27. musicdl/modules/sources/qianqian.py +125 -77
  28. musicdl/modules/sources/qq.py +232 -94
  29. musicdl/modules/sources/tidal.py +342 -0
  30. musicdl/modules/sources/ximalaya.py +256 -0
  31. musicdl/modules/sources/yinyuedao.py +144 -0
  32. musicdl/modules/sources/youtube.py +238 -0
  33. musicdl/modules/utils/__init__.py +12 -4
  34. musicdl/modules/utils/appleutils.py +563 -0
  35. musicdl/modules/utils/data.py +107 -0
  36. musicdl/modules/utils/logger.py +211 -58
  37. musicdl/modules/utils/lyric.py +73 -0
  38. musicdl/modules/utils/misc.py +335 -23
  39. musicdl/modules/utils/modulebuilder.py +75 -0
  40. musicdl/modules/utils/neteaseutils.py +81 -0
  41. musicdl/modules/utils/qqutils.py +184 -0
  42. musicdl/modules/utils/quarkparser.py +105 -0
  43. musicdl/modules/utils/songinfoutils.py +54 -0
  44. musicdl/modules/utils/tidalutils.py +738 -0
  45. musicdl/modules/utils/youtubeutils.py +3606 -0
  46. musicdl/musicdl.py +184 -86
  47. musicdl-2.7.3.dist-info/LICENSE +203 -0
  48. musicdl-2.7.3.dist-info/METADATA +704 -0
  49. musicdl-2.7.3.dist-info/RECORD +53 -0
  50. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/WHEEL +5 -5
  51. musicdl-2.7.3.dist-info/entry_points.txt +2 -0
  52. musicdl/modules/sources/baiduFlac.py +0 -69
  53. musicdl/modules/sources/xiami.py +0 -104
  54. musicdl/modules/utils/downloader.py +0 -80
  55. musicdl-2.1.11.dist-info/LICENSE +0 -22
  56. musicdl-2.1.11.dist-info/METADATA +0 -82
  57. musicdl-2.1.11.dist-info/RECORD +0 -24
  58. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/top_level.txt +0 -0
  59. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/zip-safe +0 -0
@@ -1,75 +1,181 @@
1
1
  '''
2
2
  Function:
3
- 咪咕音乐下载: http://www.migu.cn/
3
+ Implementation of MiguMusicClient: https://music.migu.cn/v5/#/musicLibrary
4
4
  Author:
5
- Charles
6
- 微信公众号:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
7
  Charles的皮卡丘
8
8
  '''
9
- import requests
10
- from .base import Base
11
- from ..utils.misc import *
9
+ import copy
10
+ from .base import BaseMusicClient
11
+ from rich.progress import Progress
12
+ from urllib.parse import urlencode
13
+ from ..utils import byte2mb, resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, SongInfo
12
14
 
13
15
 
14
- '''咪咕音乐下载类'''
15
- class migu(Base):
16
- def __init__(self, config, logger_handle, **kwargs):
17
- super(migu, self).__init__(config, logger_handle, **kwargs)
18
- self.source = 'migu'
19
- self.__initialize()
20
- '''歌曲搜索'''
21
- def search(self, keyword):
22
- self.logger_handle.info('正在%s中搜索 ——> %s...' % (self.source, keyword))
23
- cfg = self.config.copy()
24
- params = {
25
- 'ua': 'Android_migu',
26
- 'version': '5.0.1',
27
- 'text': keyword,
28
- 'pageNo': '1',
29
- 'pageSize': cfg['search_size_per_source'],
30
- 'searchSwitch': '{"song":1,"album":0,"singer":0,"tagSong":0,"mvSong":0,"songlist":0,"bestShow":1}',
16
+ '''MiguMusicClient'''
17
+ class MiguMusicClient(BaseMusicClient):
18
+ source = 'MiguMusicClient'
19
+ def __init__(self, **kwargs):
20
+ super(MiguMusicClient, self).__init__(**kwargs)
21
+ self.default_search_headers = {
22
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
31
23
  }
32
- response = self.session.get(self.search_url, headers=self.headers, params=params)
33
- all_items = response.json()['songResultData']['result']
34
- songinfos = []
35
- for item in all_items:
36
- ext = ''
37
- download_url = ''
38
- filesize = '-MB'
39
- for rate in sorted(item.get('rateFormats', []), key=lambda x: int(x['size']), reverse=True):
40
- if (int(rate['size']) == 0) or (not rate.get('formatType', '')) or (not rate.get('resourceType', '')): continue
41
- ext = 'flac' if rate.get('formatType') == 'SQ' else 'mp3'
42
- download_url = self.player_url.format(
43
- copyrightId=item['copyrightId'],
44
- contentId=item['contentId'],
45
- toneFlag=rate['formatType'],
46
- resourceType=rate['resourceType']
47
- )
48
- filesize = str(round(int(rate['size'])/1024/1024, 2)) + 'MB'
49
- break
50
- if not download_url: continue
51
- duration = '-:-:-'
52
- songinfo = {
53
- 'source': self.source,
54
- 'songid': str(item['id']),
55
- 'singers': filterBadCharacter(','.join([s.get('name', '') for s in item.get('singers', [])])),
56
- 'album': filterBadCharacter(item.get('albums', [{'name': '-'}])[0].get('name', '-')),
57
- 'songname': filterBadCharacter(item.get('name', '-')),
58
- 'savedir': cfg['savedir'],
59
- 'savename': '_'.join([self.source, filterBadCharacter(item.get('name', '-'))]),
60
- 'download_url': download_url,
61
- 'filesize': filesize,
62
- 'ext': ext,
63
- 'duration': duration
64
- }
65
- songinfos.append(songinfo)
66
- if len(songinfos) == cfg['search_size_per_source']: break
67
- return songinfos
68
- '''初始化'''
69
- def __initialize(self):
70
- self.headers = {
71
- 'Referer': 'https://m.music.migu.cn/',
72
- 'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36'
24
+ self.default_download_headers = {
25
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
73
26
  }
74
- self.search_url = 'http://pd.musicapp.migu.cn/MIGUM3.0/v1.0/content/search_all.do'
75
- self.player_url = 'https://app.pd.nf.migu.cn/MIGUM3.0/v1.0/content/sub/listenSong.do?channel=mx&copyrightId={copyrightId}&contentId={contentId}&toneFlag={toneFlag}&resourceType={resourceType}&userId=15548614588710179085069&netType=00'
27
+ self.default_headers = self.default_search_headers
28
+ self._initsession()
29
+ '''_parsewithcggapi'''
30
+ def _parsewithcggapi(self, search_result: dict, request_overrides: dict = None):
31
+ # init
32
+ request_overrides, song_id = request_overrides or {}, search_result['contentId']
33
+ # _safefetchfilesize
34
+ def _safefetchfilesize(meta: dict):
35
+ if not isinstance(meta, dict): return 0
36
+ file_size = str(meta.get('size', '0.00 MB'))
37
+ file_size = file_size.removesuffix('MB').strip()
38
+ try: return float(file_size)
39
+ except: return 0
40
+ # parse
41
+ try:
42
+ try:
43
+ resp = self.get(url=f'https://api-v1.cenguigui.cn/api/mg_music/api.php?id={song_id}', timeout=10, **request_overrides)
44
+ resp.raise_for_status()
45
+ except:
46
+ resp = self.get(url=f'https://api.cenguigui.cn/api/mg_music/api.php?id={song_id}', timeout=10, **request_overrides)
47
+ resp.raise_for_status()
48
+ download_result = resp2json(resp=resp)
49
+ except:
50
+ return SongInfo(source=self.source)
51
+ for rate in sorted(safeextractfromdict(download_result, ['data', 'level', 'quality'], []), key=lambda x: _safefetchfilesize(x), reverse=True):
52
+ download_url = rate.get('url', '')
53
+ if not download_url: continue
54
+ song_info = SongInfo(
55
+ source=self.source, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
56
+ ext=str(rate.get('format', 'flac')).lower(), raw_data={'search': search_result, 'download': download_result},
57
+ )
58
+ song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
59
+ ext, file_size = song_info.download_url_status['probe_status']['ext'], song_info.download_url_status['probe_status']['file_size']
60
+ if file_size and file_size != 'NULL': song_info.file_size = file_size
61
+ if not song_info.file_size: song_info.file_size = 'NULL'
62
+ if ext and ext != 'NULL': song_info.ext = ext
63
+ if song_info.with_valid_download_url: break
64
+ # return
65
+ return song_info
66
+ '''_constructsearchurls'''
67
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
68
+ # init
69
+ rule, request_overrides = rule or {}, request_overrides or {}
70
+ # search rules
71
+ default_rule = {"text": keyword, 'pageNo': 1, 'pageSize': 10}
72
+ default_rule.update(rule)
73
+ # construct search urls based on search rules
74
+ base_url = 'https://app.u.nf.migu.cn/pc/resource/song/item/search/v1.0?'
75
+ search_urls, page_size, count = [], self.search_size_per_page, 0
76
+ while self.search_size_per_source > count:
77
+ page_rule = copy.deepcopy(default_rule)
78
+ page_rule['pageSize'] = page_size
79
+ page_rule['pageNo'] = int(count // page_size) + 1
80
+ search_urls.append(base_url + urlencode(page_rule))
81
+ count += page_size
82
+ # return
83
+ return search_urls
84
+ '''_search'''
85
+ @usesearchheaderscookies
86
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
87
+ # init
88
+ request_overrides = request_overrides or {}
89
+ # _safefetchfilesize
90
+ def _safefetchfilesize(meta: dict):
91
+ file_size = meta.get('asize') or meta.get('isize') or meta.get('size') or '0'
92
+ if byte2mb(file_size) == 'NULL': file_size = '0'
93
+ return file_size
94
+ # user info
95
+ uid = '15548614588710179085069'
96
+ # successful
97
+ try:
98
+ # --search results
99
+ resp = self.get(search_url, **request_overrides)
100
+ resp.raise_for_status()
101
+ search_results = resp2json(resp)
102
+ for search_result in search_results:
103
+ # --download results
104
+ if not isinstance(search_result, dict) or ('copyrightId' not in search_result) or ('contentId' not in search_result):
105
+ continue
106
+ song_info = SongInfo(source=self.source)
107
+ # ----try _parsewithcggapi first
108
+ try:
109
+ song_info_cgg = self._parsewithcggapi(search_result, request_overrides)
110
+ except:
111
+ song_info_cgg = SongInfo(source=self.source)
112
+ # ----general parse with official API
113
+ for rate in sorted(search_result.get('audioFormats', []), key=lambda x: int(_safefetchfilesize(x)), reverse=True):
114
+ if not isinstance(rate, dict): continue
115
+ if byte2mb(_safefetchfilesize(rate)) == 'NULL' or (not rate.get('formatType', '')) or (not rate.get('resourceType', '')): continue
116
+ ext = {'PQ': 'mp3', 'HQ': 'mp3', 'SQ': 'flac', 'ZQ24': 'flac'}.get(rate['formatType'], 'NULL')
117
+ url = (
118
+ f"https://c.musicapp.migu.cn/MIGUM3.0/strategy/listen-url/v2.4?resourceType={rate['resourceType']}&netType=01&scene="
119
+ f"&toneFlag={rate['formatType']}&contentId={search_result['contentId']}&copyrightId={search_result['copyrightId']}"
120
+ f"&lowerQualityContentId={search_result['contentId']}"
121
+ )
122
+ headers = copy.deepcopy(self.default_headers)
123
+ headers['channel'] = '014000D'
124
+ try:
125
+ resp = self.get(url, headers=headers, **request_overrides)
126
+ resp.raise_for_status()
127
+ download_result = resp2json(resp=resp)
128
+ except:
129
+ continue
130
+ download_url = safeextractfromdict(download_result, ['data', 'url'], "") or \
131
+ f"https://app.pd.nf.migu.cn/MIGUM3.0/v1.0/content/sub/listenSong.do?channel=mx&copyrightId={search_result['copyrightId']}&contentId={search_result['contentId']}&toneFlag={rate['formatType']}&resourceType={rate['resourceType']}&userId={uid}&netType=00"
132
+ song_info = SongInfo(
133
+ source=self.source, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
134
+ ext=ext, raw_data={'search': search_result, 'download': download_result},
135
+ )
136
+ if not song_info.with_valid_download_url: continue
137
+ song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
138
+ ext, file_size = song_info.download_url_status['probe_status']['ext'], song_info.download_url_status['probe_status']['file_size']
139
+ if file_size and file_size != 'NULL': song_info.file_size = file_size
140
+ if not song_info.file_size: song_info.file_size = 'NULL'
141
+ if ext and ext != 'NULL': song_info.ext = ext
142
+ if song_info_cgg.with_valid_download_url and song_info_cgg.file_size != 'NULL':
143
+ file_size_cgg = float(song_info_cgg.file_size.removesuffix('MB').strip())
144
+ file_size_official = float(song_info.file_size.removesuffix('MB').strip()) if song_info.file_size != 'NULL' else 0
145
+ if file_size_cgg > file_size_official: song_info = song_info_cgg
146
+ if song_info.with_valid_download_url: break
147
+ if not song_info.with_valid_download_url: song_info = song_info_cgg
148
+ if not song_info.with_valid_download_url: continue
149
+ # ----parse more information
150
+ song_info.update(dict(
151
+ duration_s=search_result.get('duration', 0), duration=seconds2hms(search_result.get('duration', 0)),
152
+ song_name=legalizestring(search_result.get('songName', 'NULL'), replace_null_string='NULL'),
153
+ singers=legalizestring(', '.join([singer.get('name', 'NULL') for singer in search_result.get('singerList', [])]), replace_null_string='NULL'),
154
+ album=legalizestring(search_result.get('album', 'NULL'), replace_null_string='NULL'),
155
+ identifier=f"{search_result['copyrightId']}_{search_result['contentId']}"
156
+ ))
157
+ # --lyric results
158
+ lyric_url = safeextractfromdict(search_result, ['ext', 'lrcUrl'], '') or safeextractfromdict(search_result, ['ext', 'mrcUrl'], '') or \
159
+ safeextractfromdict(search_result, ['ext', 'trcUrl'], '')
160
+ if lyric_url:
161
+ try:
162
+ resp = self.get(lyric_url, **request_overrides)
163
+ resp.encoding = 'utf-8'
164
+ lyric_result, lyric = {'lyric': resp.text}, resp.text
165
+ except:
166
+ lyric_result, lyric = {}, 'NULL'
167
+ else:
168
+ lyric_result, lyric = {}, 'NULL'
169
+ song_info.raw_data['lyric'] = lyric_result
170
+ song_info.lyric = lyric
171
+ # --append to song_infos
172
+ song_infos.append(song_info)
173
+ # --judgement for search_size
174
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
175
+ # --update progress
176
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
177
+ # failure
178
+ except Exception as err:
179
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
180
+ # return
181
+ return song_infos
@@ -0,0 +1,140 @@
1
+ '''
2
+ Function:
3
+ Implementation of MituMusicClient: https://www.qqmp3.vip/
4
+ Author:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
+ Charles的皮卡丘
8
+ '''
9
+ import copy
10
+ from .base import BaseMusicClient
11
+ from urllib.parse import urlencode
12
+ from rich.progress import Progress
13
+ from ..utils import legalizestring, usesearchheaderscookies, resp2json, safeextractfromdict, SongInfo, QuarkParser
14
+
15
+
16
+ '''MituMusicClient'''
17
+ class MituMusicClient(BaseMusicClient):
18
+ source = 'MituMusicClient'
19
+ def __init__(self, **kwargs):
20
+ super(MituMusicClient, self).__init__(**kwargs)
21
+ if not self.quark_parser_config.get('cookies'): self.logger_handle.warning(f'{self.source}.__init__ >>> "quark_parser_config" is not configured, so song downloads are restricted and only mp3 files can be downloaded.')
22
+ self.default_search_headers = {
23
+ "accept": "*/*",
24
+ "accept-encoding": "gzip, deflate, br, zstd",
25
+ "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
26
+ "origin": "https://www.qqmp3.vip",
27
+ "priority": "u=1, i",
28
+ "referer": "https://www.qqmp3.vip/",
29
+ "sec-ch-ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
30
+ "sec-ch-ua-mobile": "?0",
31
+ "sec-ch-ua-platform": '"Windows"',
32
+ "sec-fetch-dest": "empty",
33
+ "sec-fetch-mode": "cors",
34
+ "sec-fetch-site": "same-site",
35
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
36
+ }
37
+ self.default_download_headers = {
38
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
39
+ }
40
+ self.default_headers = self.default_search_headers
41
+ self._initsession()
42
+ '''_constructsearchurls'''
43
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
44
+ # init
45
+ rule, request_overrides = rule or {}, request_overrides or {}
46
+ # search rules
47
+ default_rule = {'keyword': keyword, 'type': 'search'}
48
+ default_rule.update(rule)
49
+ # construct search urls based on search rules
50
+ base_url = 'https://api.qqmp3.vip/api/songs.php?'
51
+ page_rule = copy.deepcopy(default_rule)
52
+ search_urls = [base_url + urlencode(page_rule)]
53
+ self.search_size_per_page = self.search_size_per_source
54
+ # return
55
+ return search_urls
56
+ '''_search'''
57
+ @usesearchheaderscookies
58
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
59
+ # init
60
+ request_overrides = request_overrides or {}
61
+ # successful
62
+ try:
63
+ # --search results
64
+ resp = self.get(search_url, **request_overrides)
65
+ resp.raise_for_status()
66
+ search_results = resp2json(resp)['data']
67
+ for search_result in search_results:
68
+ # --download results
69
+ if not isinstance(search_result, dict) or ('rid' not in search_result):
70
+ continue
71
+ song_info = SongInfo(source=self.source)
72
+ # ----parse from quark links
73
+ if self.quark_parser_config.get('cookies'):
74
+ quark_download_urls: list[str] = search_result.get('downurl', [])
75
+ for quark_download_url in quark_download_urls:
76
+ if 'mp3' in quark_download_url.lower(): continue
77
+ song_info = SongInfo(source=self.source)
78
+ try:
79
+ quark_wav_download_url = quark_download_url[quark_download_url.index('https://'):]
80
+ download_result, download_url = QuarkParser.parsefromurl(quark_wav_download_url, **self.quark_parser_config)
81
+ if not download_url: continue
82
+ download_url_status = self.quark_audio_link_tester.test(download_url, request_overrides)
83
+ download_url_status['probe_status'] = self.quark_audio_link_tester.probe(download_url, request_overrides)
84
+ ext = download_url_status['probe_status']['ext']
85
+ if ext == 'NULL': ext = 'wav'
86
+ song_info.update(dict(
87
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
88
+ default_download_headers=self.quark_default_download_headers, ext=ext, file_size=download_url_status['probe_status']['file_size']
89
+ ))
90
+ if song_info.with_valid_download_url: break
91
+ except:
92
+ continue
93
+ # ----parse from play url
94
+ lyric_result = {}
95
+ if not song_info.with_valid_download_url:
96
+ song_info = SongInfo(source=self.source)
97
+ try:
98
+ resp = self.get(f'https://api.qqmp3.vip/api/kw.php?rid={search_result["rid"]}&type=json&level=exhigh&lrc=true', **request_overrides)
99
+ resp.raise_for_status()
100
+ download_result = resp2json(resp=resp)
101
+ download_url = download_result['data']['url']
102
+ if not download_url: continue
103
+ download_url_status = self.audio_link_tester.test(download_url, request_overrides)
104
+ download_url_status['probe_status'] = self.audio_link_tester.probe(download_url, request_overrides)
105
+ ext = download_url_status['probe_status']['ext']
106
+ if ext == 'NULL': download_url.split('.')[-1].split('?')[0] or 'mp3'
107
+ song_info.update(dict(
108
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
109
+ ext=ext, file_size=download_url_status['probe_status']['file_size']
110
+ ))
111
+ except:
112
+ continue
113
+ lyric_result = copy.deepcopy(download_result)
114
+ else:
115
+ try:
116
+ resp = self.get(f'https://api.qqmp3.vip/api/kw.php?rid={search_result["rid"]}&type=json&level=exhigh&lrc=true', **request_overrides)
117
+ resp.raise_for_status()
118
+ lyric_result = resp2json(resp=resp)
119
+ except:
120
+ pass
121
+ if not song_info.with_valid_download_url: continue
122
+ # ----parse more infos
123
+ lyric = safeextractfromdict(lyric_result, ['data', 'lrc'], '')
124
+ if not lyric or '歌词获取失败' in lyric: lyric = 'NULL'
125
+ song_info.raw_data['lyric'] = lyric_result
126
+ song_info.update(dict(
127
+ lyric=lyric, duration='-:-:-', song_name=legalizestring(search_result.get('name', 'NULL'), replace_null_string='NULL'),
128
+ singers=legalizestring(search_result.get('artist', 'NULL'), replace_null_string='NULL'), album='NULL', identifier=search_result['rid'],
129
+ ))
130
+ # --append to song_infos
131
+ song_infos.append(song_info)
132
+ # --judgement for search_size
133
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
134
+ # --update progress
135
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
136
+ # failure
137
+ except Exception as err:
138
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
139
+ # return
140
+ return song_infos
@@ -0,0 +1,264 @@
1
+ '''
2
+ Function:
3
+ Implementation of MP3JuiceMusicClient: https://mp3juice.co/
4
+ Author:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
+ Charles的皮卡丘
8
+ '''
9
+ import re
10
+ import json
11
+ import copy
12
+ import time
13
+ import base64
14
+ from bs4 import BeautifulSoup
15
+ from urllib.parse import quote
16
+ from .base import BaseMusicClient
17
+ from itertools import zip_longest
18
+ from urllib.parse import urlencode
19
+ from rich.progress import Progress
20
+ from typing import List, Dict, Any, Optional
21
+ from ..utils import legalizestring, usesearchheaderscookies, usedownloadheaderscookies, touchdir, resp2json, byte2mb, SongInfo, SongInfoUtils
22
+
23
+
24
+ '''MP3JuiceMusicClient'''
25
+ class MP3JuiceMusicClient(BaseMusicClient):
26
+ source = 'MP3JuiceMusicClient'
27
+ def __init__(self, **kwargs):
28
+ super(MP3JuiceMusicClient, self).__init__(**kwargs)
29
+ self.default_search_headers = {
30
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
31
+ "accept": "*/*",
32
+ "accept-encoding": "gzip, deflate, br, zstd",
33
+ "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
34
+ "priority": "u=1, i",
35
+ "referer": "https://mp3juice.co/",
36
+ "sec-ch-ua": "\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"",
37
+ "sec-ch-ua-mobile": "?0",
38
+ "sec-ch-ua-platform": "\"Windows\"",
39
+ "sec-fetch-dest": "empty",
40
+ "sec-fetch-mode": "cors",
41
+ "sec-fetch-site": "same-origin",
42
+ }
43
+ self.default_download_headers = {
44
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
45
+ "accept": "*/*",
46
+ "accept-encoding": "gzip, deflate, br, zstd",
47
+ "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
48
+ "priority": "u=1, i",
49
+ "referer": "https://mp3juice.co/",
50
+ "sec-ch-ua": "\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"",
51
+ "sec-ch-ua-mobile": "?0",
52
+ "sec-ch-ua-platform": "\"Windows\"",
53
+ "sec-fetch-dest": "empty",
54
+ "sec-fetch-mode": "cors",
55
+ "sec-fetch-site": "same-origin",
56
+ }
57
+ self.default_headers = self.default_search_headers
58
+ self._initsession()
59
+ '''_download'''
60
+ @usedownloadheaderscookies
61
+ def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0):
62
+ request_overrides = request_overrides or {}
63
+ try:
64
+ touchdir(song_info.work_dir)
65
+ total_size = song_info.downloaded_contents.__sizeof__()
66
+ progress.update(song_progress_id, total=total_size)
67
+ with open(song_info.save_path, "wb") as fp:
68
+ fp.write(song_info.downloaded_contents)
69
+ progress.advance(song_progress_id, total_size)
70
+ progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name} (Success)")
71
+ downloaded_song_infos.append(SongInfoUtils.fillsongtechinfo(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print))
72
+ except Exception as err:
73
+ progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name} (Error: {err})")
74
+ return downloaded_song_infos
75
+ '''_decodebin'''
76
+ def _decodebin(self, bin_str: str) -> List[int]:
77
+ return [int(b, 2) for b in bin_str.split() if b]
78
+ '''_decodehex'''
79
+ def _decodehex(self, hex_str: str) -> bytes:
80
+ tokens = re.findall(r'0x[0-9a-fA-F]{2}', hex_str)
81
+ return bytes(int(h, 16) for h in tokens)
82
+ '''_authorization'''
83
+ def _authorization(self, gc: Dict[str, Any]) -> str:
84
+ bin_str, secret_b64 = gc["Ffw"]
85
+ flag_reverse, offset, max_len, case_mode = gc["LUy"]
86
+ hex_str = gc["Ixn"][0]
87
+ secret_bytes: bytes = base64.b64decode(secret_b64)
88
+ if flag_reverse > 0: secret_bytes = secret_bytes[::-1]
89
+ idx_list = self._decodebin(bin_str)
90
+ t: bytes = bytes(secret_bytes[i - offset] for i in idx_list)
91
+ if max_len > 0: t = t[:max_len]
92
+ if case_mode == 1: t = t.decode("latin1").lower().encode("latin1")
93
+ elif case_mode == 2: t = t.decode("latin1").upper().encode("latin1")
94
+ suffix: bytes = self._decodehex(hex_str)
95
+ raw: bytes = t + b"_" + suffix
96
+ return base64.b64encode(raw).decode("ascii")
97
+ '''_extractgcfromhtml'''
98
+ def _extractgcfromhtml(self, html: str) -> Dict[str, Any]:
99
+ soup = BeautifulSoup(html, "html.parser")
100
+ gc = self._tryextractgcnewstyle(soup)
101
+ if gc is not None: return gc
102
+ gc = self._tryextractgcoldstyle(soup)
103
+ if gc is not None: return gc
104
+ raise RuntimeError("Failed to extract gc config from HTML")
105
+ '''_tryextractgcnewstyle'''
106
+ def _tryextractgcnewstyle(self, soup) -> Optional[Dict[str, Any]]:
107
+ for script in soup.find_all("script"):
108
+ text = (script.string or script.get_text() or "").strip()
109
+ if "Object.defineProperty(gC" not in text: continue
110
+ for m in re.finditer(r"var\s+(\w+)\s*=\s*(\{.*?});", text, flags=re.S):
111
+ obj_literal = m.group(2)
112
+ try: y_dict = json.loads(obj_literal)
113
+ except json.JSONDecodeError: continue
114
+ gc = self._mapylikedicttogc(y_dict)
115
+ if gc is not None: return gc
116
+ return None
117
+ '''_tryextractgcoldstyle'''
118
+ def _tryextractgcoldstyle(self, soup) -> Dict[str, Any] | None:
119
+ for script in soup.find_all("script"):
120
+ text = script.string or ""
121
+ if "var gC" not in text or "dfU" not in text: continue
122
+ m = re.search(r"var\s+j\s*=\s*(\{.*?});", text, flags=re.S)
123
+ if not m: continue
124
+ j_obj_str = m.group(1)
125
+ try: j_dict = json.loads(j_obj_str)
126
+ except json.JSONDecodeError: continue
127
+ key_names = [base64.b64decode(x).decode("utf-8") for x in j_dict["dfU"]]
128
+ sub = {name: j_dict[name] for name in key_names}
129
+ gc = self._mapylikedicttogc(sub)
130
+ if gc is not None: return gc
131
+ return None
132
+ '''_mapylikedicttogc'''
133
+ def _mapylikedicttogc(self, d: Dict[str, Any]) -> Dict[str, Any] | None:
134
+ f_key = l_key = i_key = None
135
+ for k, v in d.items():
136
+ if not isinstance(v, list): continue
137
+ if len(v) == 4 and all(isinstance(x, int) for x in v): l_key = k; continue
138
+ if len(v) == 2 and isinstance(v[0], str):
139
+ if re.fullmatch(r"[01 ]+", v[0]): f_key = k; continue
140
+ if re.search(r"0x[0-9a-fA-F]{2}", v[0]): i_key = k; continue
141
+ if f_key and l_key and i_key: return {"Ffw": d[f_key], "LUy": d[l_key], "Ixn": d[i_key]}
142
+ return None
143
+ '''_getinitparamname'''
144
+ def _getinitparamname(self, gc: dict) -> str:
145
+ hex_param = gc["Ixn"][1]
146
+ name_bytes = self._decodehex(hex_param)
147
+ return name_bytes.decode("latin1")
148
+ '''_constructsearchurls'''
149
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
150
+ # init
151
+ rule, request_overrides = rule or {}, request_overrides or {}
152
+ resp = self.get('https://mp3juice.co/', **request_overrides)
153
+ resp.raise_for_status()
154
+ gc = self._extractgcfromhtml(resp.text)
155
+ auth_code = self._authorization(gc=gc)
156
+ init_param_name = self._getinitparamname(gc)
157
+ # search rules
158
+ default_rule = {'a': auth_code, 'y': 's', 'q': keyword, 't': str(int(time.time()))}
159
+ default_rule.update(rule)
160
+ default_rule['q'] = base64.b64encode(quote(keyword, safe="").encode("utf-8")).decode("utf-8")
161
+ # construct search urls based on search rules
162
+ base_url = 'https://mp3juice.co/api/v1/search?'
163
+ page_rule = copy.deepcopy(default_rule)
164
+ search_urls = [{'url': base_url + urlencode(page_rule), 'auth_code': auth_code, 'init_param_name': init_param_name}]
165
+ self.search_size_per_page = self.search_size_per_source
166
+ # return
167
+ return search_urls
168
+ '''_search'''
169
+ @usesearchheaderscookies
170
+ def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
171
+ # init
172
+ request_overrides, search_meta = request_overrides or {}, copy.deepcopy(search_url)
173
+ search_url, auth_code, init_param_name = search_meta['url'], search_meta['auth_code'], search_meta['init_param_name']
174
+ # successful
175
+ try:
176
+ # --search results
177
+ resp = self.get(search_url, **request_overrides)
178
+ resp.raise_for_status()
179
+ search_results_yt, search_results_sc = [], []
180
+ for item in resp2json(resp)["yt"]: item['root_source'] = 'YouTube'; search_results_yt.append(item)
181
+ for item in resp2json(resp)["sc"]: item['root_source'] = 'SoundCloud'; search_results_sc.append(item)
182
+ search_results = [x for ab in zip_longest(search_results_yt, search_results_sc) for x in ab if x is not None]
183
+ for search_result in search_results:
184
+ # --judgement for search_size
185
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
186
+ # --download results
187
+ if not isinstance(search_result, dict) or ('id' not in search_result):
188
+ continue
189
+ song_info, download_result = SongInfo(source=self.source, root_source=search_result['root_source']), dict()
190
+ # ----song name and lyric
191
+ lyric_result, lyric = dict(), 'NULL'
192
+ singers_song_name = search_result.get('title', 'NULL-NULL').split('-')
193
+ if len(singers_song_name) == 1:
194
+ singers, song_name = 'NULL', singers_song_name[0].strip()
195
+ elif len(singers_song_name) > 1:
196
+ singers, song_name = singers_song_name[0].strip(), singers_song_name[1].strip()
197
+ song_info.raw_data['lyric'] = lyric_result
198
+ song_info.update(dict(
199
+ lyric=lyric, duration='-:-:-', song_name=legalizestring(song_name, replace_null_string='NULL'), singers=legalizestring(singers, replace_null_string='NULL'),
200
+ album='NULL', identifier=search_result['id'],
201
+ ))
202
+ # ----if in sound cloud, can be directly accessed
203
+ if search_result['root_source'] in ['SoundCloud']:
204
+ try:
205
+ download_url = f"https://eooc.cc/s/{search_result['id_base64']}/{search_result['title_base64']}/"
206
+ download_url_status = self.audio_link_tester.test(download_url, request_overrides)
207
+ song_info.update(dict(
208
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': {}}, ext='mp3',
209
+ ))
210
+ if not song_info.with_valid_download_url: continue
211
+ song_info.downloaded_contents = self.get(download_url, **request_overrides).content
212
+ song_info.file_size_bytes = song_info.downloaded_contents.__sizeof__()
213
+ song_info.file_size = byte2mb(song_info.file_size_bytes)
214
+ song_infos.append(song_info)
215
+ except:
216
+ continue
217
+ # ----init
218
+ params = {init_param_name: auth_code, 't': str(int(time.time()))}
219
+ try:
220
+ resp = self.get('https://www1.eooc.cc/api/v1/init?', params=params, **request_overrides)
221
+ resp.raise_for_status()
222
+ download_result['init'] = resp2json(resp=resp)
223
+ convert_url = download_result['init'].get('convertURL', '')
224
+ if not convert_url: continue
225
+ except:
226
+ continue
227
+ # ----convert
228
+ convert_url = f'{convert_url}&v={search_result["id"]}&f=mp3&t={str(int(time.time()))}'
229
+ try:
230
+ resp = self.get(convert_url, **request_overrides)
231
+ resp.raise_for_status()
232
+ download_result['conver'] = resp2json(resp=resp)
233
+ redirect_url = download_result['conver'].get('redirectURL', '')
234
+ if not redirect_url: continue
235
+ except:
236
+ continue
237
+ # ----redirect
238
+ try:
239
+ resp = self.get(redirect_url, **request_overrides)
240
+ resp.raise_for_status()
241
+ download_result['redirect'] = resp2json(resp=resp)
242
+ download_url: str = download_result['redirect'].get('downloadURL', '')
243
+ if not download_url: continue
244
+ except:
245
+ continue
246
+ # ----test
247
+ download_url_status = self.audio_link_tester.test(download_url, request_overrides)
248
+ song_info.update(dict(
249
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result}, ext='mp3',
250
+ ))
251
+ if not song_info.with_valid_download_url: continue
252
+ # ----download should be directly conducted otherwise will have 404 errors
253
+ song_info.downloaded_contents = self.get(download_url, **request_overrides).content
254
+ song_info.file_size_bytes = song_info.downloaded_contents.__sizeof__()
255
+ song_info.file_size = byte2mb(song_info.file_size_bytes)
256
+ # --append to song_infos
257
+ song_infos.append(song_info)
258
+ # --update progress
259
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
260
+ # failure
261
+ except Exception as err:
262
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
263
+ # return
264
+ return song_infos