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.
- musicdl/__init__.py +5 -5
- musicdl/modules/__init__.py +10 -3
- musicdl/modules/common/__init__.py +2 -0
- musicdl/modules/common/gdstudio.py +204 -0
- musicdl/modules/js/__init__.py +1 -0
- musicdl/modules/js/youtube/__init__.py +2 -0
- musicdl/modules/js/youtube/botguard.js +1 -0
- musicdl/modules/js/youtube/jsinterp.py +902 -0
- musicdl/modules/js/youtube/runner.js +2 -0
- musicdl/modules/sources/__init__.py +41 -10
- musicdl/modules/sources/apple.py +207 -0
- musicdl/modules/sources/base.py +256 -28
- musicdl/modules/sources/bilibili.py +118 -0
- musicdl/modules/sources/buguyy.py +148 -0
- musicdl/modules/sources/fangpi.py +153 -0
- musicdl/modules/sources/fivesing.py +108 -0
- musicdl/modules/sources/gequbao.py +148 -0
- musicdl/modules/sources/jamendo.py +108 -0
- musicdl/modules/sources/joox.py +104 -68
- musicdl/modules/sources/kugou.py +129 -76
- musicdl/modules/sources/kuwo.py +188 -68
- musicdl/modules/sources/lizhi.py +107 -0
- musicdl/modules/sources/migu.py +172 -66
- musicdl/modules/sources/mitu.py +140 -0
- musicdl/modules/sources/mp3juice.py +264 -0
- musicdl/modules/sources/netease.py +163 -115
- musicdl/modules/sources/qianqian.py +125 -77
- musicdl/modules/sources/qq.py +232 -94
- musicdl/modules/sources/tidal.py +342 -0
- musicdl/modules/sources/ximalaya.py +256 -0
- musicdl/modules/sources/yinyuedao.py +144 -0
- musicdl/modules/sources/youtube.py +238 -0
- musicdl/modules/utils/__init__.py +12 -4
- musicdl/modules/utils/appleutils.py +563 -0
- musicdl/modules/utils/data.py +107 -0
- musicdl/modules/utils/logger.py +211 -58
- musicdl/modules/utils/lyric.py +73 -0
- musicdl/modules/utils/misc.py +335 -23
- musicdl/modules/utils/modulebuilder.py +75 -0
- musicdl/modules/utils/neteaseutils.py +81 -0
- musicdl/modules/utils/qqutils.py +184 -0
- musicdl/modules/utils/quarkparser.py +105 -0
- musicdl/modules/utils/songinfoutils.py +54 -0
- musicdl/modules/utils/tidalutils.py +738 -0
- musicdl/modules/utils/youtubeutils.py +3606 -0
- musicdl/musicdl.py +184 -86
- musicdl-2.7.3.dist-info/LICENSE +203 -0
- musicdl-2.7.3.dist-info/METADATA +704 -0
- musicdl-2.7.3.dist-info/RECORD +53 -0
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/WHEEL +5 -5
- musicdl-2.7.3.dist-info/entry_points.txt +2 -0
- musicdl/modules/sources/baiduFlac.py +0 -69
- musicdl/modules/sources/xiami.py +0 -104
- musicdl/modules/utils/downloader.py +0 -80
- musicdl-2.1.11.dist-info/LICENSE +0 -22
- musicdl-2.1.11.dist-info/METADATA +0 -82
- musicdl-2.1.11.dist-info/RECORD +0 -24
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/top_level.txt +0 -0
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/zip-safe +0 -0
musicdl/modules/sources/migu.py
CHANGED
|
@@ -1,75 +1,181 @@
|
|
|
1
1
|
'''
|
|
2
2
|
Function:
|
|
3
|
-
|
|
3
|
+
Implementation of MiguMusicClient: https://music.migu.cn/v5/#/musicLibrary
|
|
4
4
|
Author:
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Zhenchao Jin
|
|
6
|
+
WeChat Official Account (微信公众号):
|
|
7
7
|
Charles的皮卡丘
|
|
8
8
|
'''
|
|
9
|
-
import
|
|
10
|
-
from .base import
|
|
11
|
-
from
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
self.
|
|
19
|
-
self.
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
75
|
-
self.
|
|
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']}©rightId={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©rightId={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
|