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/__init__.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
'''title'''
|
|
2
2
|
__title__ = 'musicdl'
|
|
3
3
|
'''description'''
|
|
4
|
-
__description__ = 'A lightweight music downloader written
|
|
4
|
+
__description__ = 'Musicdl: A lightweight music downloader written in pure python'
|
|
5
5
|
'''url'''
|
|
6
6
|
__url__ = 'https://github.com/CharlesPikachu/musicdl'
|
|
7
7
|
'''version'''
|
|
8
|
-
__version__ = '2.
|
|
8
|
+
__version__ = '2.7.3'
|
|
9
9
|
'''author'''
|
|
10
|
-
__author__ = '
|
|
10
|
+
__author__ = 'Zhenchao Jin'
|
|
11
11
|
'''email'''
|
|
12
12
|
__email__ = 'charlesblwx@gmail.com'
|
|
13
13
|
'''license'''
|
|
14
|
-
__license__ = '
|
|
14
|
+
__license__ = 'Apache License 2.0'
|
|
15
15
|
'''copyright'''
|
|
16
|
-
__copyright__ = 'Copyright 2020
|
|
16
|
+
__copyright__ = 'Copyright 2020-2030 Zhenchao Jin'
|
musicdl/modules/__init__.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
'''
|
|
2
|
-
from .
|
|
3
|
-
|
|
1
|
+
'''initialize'''
|
|
2
|
+
from .sources import (
|
|
3
|
+
MusicClientBuilder, BuildMusicClient
|
|
4
|
+
)
|
|
5
|
+
from .utils import (
|
|
6
|
+
BaseModuleBuilder, LoggerHandle, AudioLinkTester, WhisperLRC, QuarkParser, SongInfo, SongInfoUtils, colorize, printtable, legalizestring,
|
|
7
|
+
cachecookies, resp2json, isvalidresp, safeextractfromdict, replacefile, printfullline, smarttrunctable, usesearchheaderscookies, byte2mb,
|
|
8
|
+
usedownloadheaderscookies, useparseheaderscookies, cookies2dict, cookies2string, touchdir, seconds2hms, estimatedurationwithfilesizebr,
|
|
9
|
+
estimatedurationwithfilelink,
|
|
10
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Function:
|
|
3
|
+
Implementation of GDStudioMusicClient: https://music.gdstudio.xyz/
|
|
4
|
+
Author:
|
|
5
|
+
Zhenchao Jin
|
|
6
|
+
WeChat Official Account (微信公众号):
|
|
7
|
+
Charles的皮卡丘
|
|
8
|
+
'''
|
|
9
|
+
import copy
|
|
10
|
+
import time
|
|
11
|
+
import random
|
|
12
|
+
import hashlib
|
|
13
|
+
import requests
|
|
14
|
+
import json_repair
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
from rich.progress import Progress
|
|
17
|
+
from ..sources import BaseMusicClient
|
|
18
|
+
from ..utils import legalizestring, resp2json, usesearchheaderscookies, byte2mb, estimatedurationwithfilesizebr, estimatedurationwithfilelink, seconds2hms, SongInfo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
'''SUPPORTED_SITES'''
|
|
22
|
+
SUPPORTED_SITES = [
|
|
23
|
+
'spotify', 'tencent', 'netease', 'kuwo', 'tidal', 'qobuz', 'joox', 'bilibili', 'apple', 'ytmusic', # 'kugou', 'ximalaya', 'migu',
|
|
24
|
+
]
|
|
25
|
+
SITE_TO_API_MAPPER = {
|
|
26
|
+
'kuwo': 'https://music.gdstudio.xyz/api.php', 'tencent': 'https://music.gdstudio.xyz/api.php', 'tidal': 'https://music.gdstudio.xyz/api.php',
|
|
27
|
+
'spotify': 'https://music.gdstudio.xyz/api.php', 'netease': 'https://music.gdstudio.xyz/api.php', 'bilibili': 'https://music.gdstudio.xyz/api.php',
|
|
28
|
+
'apple': 'https://music.gdstudio.xyz/api.php',
|
|
29
|
+
'migu': 'https://music-api-cn.gdstudio.xyz/api.php', 'kugou': 'https://music-api-cn.gdstudio.xyz/api.php', 'ximalaya': 'https://music-api-cn.gdstudio.xyz/api.php', # useless with error code 503
|
|
30
|
+
'joox': 'https://music-api-hk.gdstudio.xyz/api.php',
|
|
31
|
+
'qobuz': 'https://music-api-us.gdstudio.xyz/api.php', 'ytmusic': 'https://music-api-us.gdstudio.xyz/api.php',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
'''GDStudioMusicClient'''
|
|
36
|
+
class GDStudioMusicClient(BaseMusicClient):
|
|
37
|
+
source = 'GDStudioMusicClient'
|
|
38
|
+
def __init__(self, **kwargs):
|
|
39
|
+
self.allowed_music_sources = list(set(kwargs.pop('allowed_music_sources', SUPPORTED_SITES[:-1])))
|
|
40
|
+
super(GDStudioMusicClient, self).__init__(**kwargs)
|
|
41
|
+
self.default_search_headers = {
|
|
42
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
43
|
+
'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',
|
|
44
|
+
}
|
|
45
|
+
self.default_download_headers = {
|
|
46
|
+
'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',
|
|
47
|
+
}
|
|
48
|
+
self.default_headers = self.default_search_headers
|
|
49
|
+
self._initsession()
|
|
50
|
+
'''_yieldcallback'''
|
|
51
|
+
def _yieldcallback(self):
|
|
52
|
+
random_num = ''.join([str(random.randint(0, 9)) for _ in range(21)])
|
|
53
|
+
timestamp = int(time.time() * 1000)
|
|
54
|
+
return f"jQuery{random_num}_{timestamp}"
|
|
55
|
+
'''_yieldcrc32'''
|
|
56
|
+
def _yieldcrc32(self, id_value: str, hostname: str = 'music.gdstudio.xyz', version: str = "2025.11.4"):
|
|
57
|
+
# timestamp
|
|
58
|
+
try:
|
|
59
|
+
resp = self.get('https://www.ximalaya.com/revision/time')
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
ts_ms = resp.text.strip()
|
|
62
|
+
except:
|
|
63
|
+
ts_ms = int(time.time() * 1000)
|
|
64
|
+
ts9 = str(ts_ms)[:9]
|
|
65
|
+
# version
|
|
66
|
+
parts = version.split(".")
|
|
67
|
+
padded = [p if len(p) != 1 else "0" + p for p in parts]
|
|
68
|
+
ver_padded = "".join(padded)
|
|
69
|
+
# id
|
|
70
|
+
id_str = quote(str(id_value))
|
|
71
|
+
# src
|
|
72
|
+
src = f"{hostname}|{ver_padded}|{ts9}|{id_str}"
|
|
73
|
+
# return
|
|
74
|
+
return hashlib.md5(src.encode("utf-8")).hexdigest()[-8:].upper()
|
|
75
|
+
'''_constructsearchurls'''
|
|
76
|
+
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
|
77
|
+
# init
|
|
78
|
+
rule, request_overrides = rule or {}, request_overrides or {}
|
|
79
|
+
allowed_music_sources = copy.deepcopy(self.allowed_music_sources)
|
|
80
|
+
# search rules
|
|
81
|
+
default_rule = {'types': 'search', 'count': self.search_size_per_page, 'pages': '1', 'name': keyword}
|
|
82
|
+
default_rule.update(rule)
|
|
83
|
+
# construct search urls based on search rules
|
|
84
|
+
search_urls, page_size = [], self.search_size_per_page
|
|
85
|
+
for source in SUPPORTED_SITES:
|
|
86
|
+
if source not in allowed_music_sources: continue
|
|
87
|
+
source_default_rule = copy.deepcopy(default_rule)
|
|
88
|
+
source_default_rule['source'], count = source, 0
|
|
89
|
+
while self.search_size_per_source > count:
|
|
90
|
+
if SITE_TO_API_MAPPER[source] in ['https://music.gdstudio.xyz/api.php']:
|
|
91
|
+
page_rule_post = copy.deepcopy(source_default_rule)
|
|
92
|
+
page_rule_post['pages'] = str(int(count // page_size) + 1)
|
|
93
|
+
page_rule_post['count'] = str(page_size)
|
|
94
|
+
page_rule_post['s'] = self._yieldcrc32(keyword)
|
|
95
|
+
search_urls.append({
|
|
96
|
+
'url': SITE_TO_API_MAPPER[source], 'data': page_rule_post, 'params': {'callback': self._yieldcallback()}, 'method': 'post'
|
|
97
|
+
})
|
|
98
|
+
else:
|
|
99
|
+
page_rule_get = copy.deepcopy(source_default_rule)
|
|
100
|
+
page_rule_get['pages'] = str(int(count // page_size) + 1)
|
|
101
|
+
page_rule_get['count'] = str(page_size)
|
|
102
|
+
page_rule_get['s'] = self._yieldcrc32(keyword)
|
|
103
|
+
page_rule_get['callback'] = self._yieldcallback()
|
|
104
|
+
page_rule_get['_'] = str(int(time.time() * 1000))
|
|
105
|
+
search_urls.append({
|
|
106
|
+
'url': SITE_TO_API_MAPPER[source], 'params': page_rule_get, 'method': 'get'
|
|
107
|
+
})
|
|
108
|
+
count += page_size
|
|
109
|
+
# return
|
|
110
|
+
return search_urls
|
|
111
|
+
'''_search'''
|
|
112
|
+
@usesearchheaderscookies
|
|
113
|
+
def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
|
|
114
|
+
# init
|
|
115
|
+
request_overrides = request_overrides or {}
|
|
116
|
+
search_meta = copy.deepcopy(search_url)
|
|
117
|
+
search_url, method = search_meta.pop('url'), search_meta.pop('method')
|
|
118
|
+
self.default_headers, request_overrides = copy.deepcopy(self.default_headers), copy.deepcopy(request_overrides)
|
|
119
|
+
# successful
|
|
120
|
+
try:
|
|
121
|
+
# --search results
|
|
122
|
+
resp: requests.Response = getattr(self, method)(search_url, **search_meta, **request_overrides)
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
json_str = resp.text[resp.text.index('(')+1: resp.text.rindex(')')]
|
|
125
|
+
search_results = json_repair.loads(json_str)
|
|
126
|
+
for search_result in search_results:
|
|
127
|
+
# --download results
|
|
128
|
+
if (not isinstance(search_result, dict)) or ('id' not in search_result) or ('url_id' not in search_result) or ('source' not in search_result): continue
|
|
129
|
+
song_info = SongInfo(source=self.source, root_source=search_result['source'])
|
|
130
|
+
for br in [999, 740, 320, 192, 128]: # 999 and 740 mean lossless
|
|
131
|
+
params = {'callback': self._yieldcallback()}
|
|
132
|
+
data_json = {'types': 'url', 'id': search_result['id'], 'source': search_result['source'], 'br': br, 's': self._yieldcrc32(search_result['id'])}
|
|
133
|
+
try:
|
|
134
|
+
if method == 'post':
|
|
135
|
+
resp = self.post(SITE_TO_API_MAPPER[search_result['source']], params=params, data=data_json, **request_overrides)
|
|
136
|
+
else:
|
|
137
|
+
resp = self.get(SITE_TO_API_MAPPER[search_result['source']], params={**params, **data_json, '_': str(int(time.time() * 1000))}, **request_overrides)
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
json_str = resp.text[resp.text.index('(')+1: resp.text.rindex(')')]
|
|
140
|
+
download_result = json_repair.loads(json_str)
|
|
141
|
+
except:
|
|
142
|
+
continue
|
|
143
|
+
if not download_result.get('url'): continue
|
|
144
|
+
download_url = download_result['url']
|
|
145
|
+
if not download_url.startswith('http'): download_url = f'https://music.gdstudio.xyz/' + download_url
|
|
146
|
+
if search_result['source'] in ['bilibili']: download_url = f'https://music-proxy.gdstudio.org/{download_url}'
|
|
147
|
+
song_info = SongInfo(
|
|
148
|
+
source=self.source, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
|
149
|
+
ext=download_url.split('.')[-1].split('?')[0], file_size_bytes=download_result.get('size', 0), file_size=byte2mb(download_result.get('size', 0)),
|
|
150
|
+
duration=estimatedurationwithfilesizebr(download_result.get('size', 0), download_result.get('br', br)),
|
|
151
|
+
duration_s=estimatedurationwithfilesizebr(download_result.get('size', 0), download_result.get('br', br), return_seconds=True),
|
|
152
|
+
raw_data={'search': search_result, 'download': download_result}, identifier=f"{search_result['source']}_{search_result['id']}",
|
|
153
|
+
song_name=legalizestring(search_result.get('name', 'NULL'), replace_null_string='NULL'),
|
|
154
|
+
singers=legalizestring(', '.join(search_result.get('artist', 'NULL')), replace_null_string='NULL'),
|
|
155
|
+
album=legalizestring(search_result.get('album', 'NULL'), replace_null_string='NULL'), root_source=search_result['source'],
|
|
156
|
+
)
|
|
157
|
+
if search_result['source'] in ['bilibili']: song_info.download_url_status['ok'] = True if song_info.download_url_status['clen'] > 0 else False # use proxy url, general test method will fail
|
|
158
|
+
if song_info.with_valid_download_url: break
|
|
159
|
+
if not song_info.with_valid_download_url: continue
|
|
160
|
+
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
|
|
161
|
+
ext, file_size = song_info.download_url_status['probe_status']['ext'], song_info.download_url_status['probe_status']['file_size']
|
|
162
|
+
if file_size and file_size != 'NULL': song_info.file_size = file_size
|
|
163
|
+
if ext and ext != 'NULL': song_info.ext = ext
|
|
164
|
+
if song_info.ext == 'm4s': song_info.ext = 'm4a'
|
|
165
|
+
# --lyric results
|
|
166
|
+
try:
|
|
167
|
+
params = {'callback': self._yieldcallback()}
|
|
168
|
+
data_json = {'types': 'lyric', 'id': search_result['lyric_id'], 'source': search_result['source'], 's': self._yieldcrc32(search_result['lyric_id'])}
|
|
169
|
+
if method == 'post':
|
|
170
|
+
resp = self.post(SITE_TO_API_MAPPER[search_result['source']], data=data_json, params=params, **request_overrides)
|
|
171
|
+
else:
|
|
172
|
+
resp = self.get(SITE_TO_API_MAPPER[search_result['source']], params={**params, **data_json, '_': str(int(time.time() * 1000))}, **request_overrides)
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
json_str = resp.text[resp.text.index('(')+1: resp.text.rindex(')')]
|
|
175
|
+
lyric_result = json_repair.loads(json_str)
|
|
176
|
+
lyric = lyric_result.get('lyric') or lyric_result.get('tlyric') or 'NULL'
|
|
177
|
+
except:
|
|
178
|
+
lyric_result, lyric = dict(), 'NULL'
|
|
179
|
+
if not lyric or lyric == 'NULL':
|
|
180
|
+
try:
|
|
181
|
+
params = {
|
|
182
|
+
'artist_name': song_info.singers, 'track_name': song_info.song_name, 'album_name': song_info.album,
|
|
183
|
+
'duration': estimatedurationwithfilelink(song_info.download_url, headers=self.default_download_headers, request_overrides=request_overrides),
|
|
184
|
+
}
|
|
185
|
+
resp = self.get(f'https://lrclib.net/api/get?', params=params, **request_overrides)
|
|
186
|
+
resp.raise_for_status()
|
|
187
|
+
lyric_result = resp2json(resp=resp)
|
|
188
|
+
lyric = lyric_result.get('syncedLyrics') or lyric_result.get('plainLyrics')
|
|
189
|
+
if lyric: song_info.duration_s, song_info.duration = params['duration'], seconds2hms(params['duration'])
|
|
190
|
+
except:
|
|
191
|
+
lyric_result, lyric = dict(), 'NULL'
|
|
192
|
+
song_info.lyric = lyric
|
|
193
|
+
song_info.raw_data['lyric'] = lyric_result
|
|
194
|
+
# --append to song_infos
|
|
195
|
+
song_infos.append(song_info)
|
|
196
|
+
# --judgement for search_size
|
|
197
|
+
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
|
|
198
|
+
# --update progress
|
|
199
|
+
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
|
|
200
|
+
# failure
|
|
201
|
+
except Exception as err:
|
|
202
|
+
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
|
|
203
|
+
# return
|
|
204
|
+
return song_infos
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
'''initialize'''
|