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
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 by pure python'
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.1.11'
8
+ __version__ = '2.7.3'
9
9
  '''author'''
10
- __author__ = 'Charles'
10
+ __author__ = 'Zhenchao Jin'
11
11
  '''email'''
12
12
  __email__ = 'charlesblwx@gmail.com'
13
13
  '''license'''
14
- __license__ = 'MIT License'
14
+ __license__ = 'Apache License 2.0'
15
15
  '''copyright'''
16
- __copyright__ = 'Copyright 2020 Charles'
16
+ __copyright__ = 'Copyright 2020-2030 Zhenchao Jin'
@@ -1,3 +1,10 @@
1
- '''import all'''
2
- from .utils import *
3
- from .sources import *
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,2 @@
1
+ '''initialize'''
2
+ from .gdstudio import GDStudioMusicClient
@@ -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'''
@@ -0,0 +1,2 @@
1
+ '''initialize'''
2
+ from .jsinterp import JSInterpreter, extractplayerjsglobalvar