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
@@ -0,0 +1,118 @@
1
+ '''
2
+ Function:
3
+ Implementation of BilibiliMusicClient: https://www.bilibili.com/audio/home/?type=9
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, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, SongInfo
14
+
15
+
16
+ '''BilibiliMusicClient'''
17
+ class BilibiliMusicClient(BaseMusicClient):
18
+ source = 'BilibiliMusicClient'
19
+ def __init__(self, **kwargs):
20
+ super(BilibiliMusicClient, 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/121.0.0.0 Safari/537.36 Edg/121.0.0.0",
23
+ "Sec-Ch-Ua": '"Not A(Brand";v="99", "Microsoft Edge";v="121", "Chromium";v="121"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"Windows"',
24
+ "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Accept-Encoding": "gzip, deflate",
25
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5",
26
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
27
+ "Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Referer": "https://www.bilibili.com/",
28
+ }
29
+ self.default_download_headers = copy.deepcopy(self.default_search_headers)
30
+ self.default_headers = self.default_search_headers
31
+ if not self.default_cookies:
32
+ self.default_search_cookies = {
33
+ "buvid3": "2E109C72-251F-3827-FA8E-921FA0D7EC5291319infoc", "b_nut": "1676213591", "i-wanna-go-back": "-1", "_uuid": "2B2D7A6C-8310C-1167-F548-2F1095A6E93F290252infoc",
34
+ "buvid4": "31696B5F-BB23-8F2B-3310-8B3C55FB49D491966-023021222-WcoPnBbwgLUAZ6TJuAUN8Q%3D%3D", "CURRENT_FNVAL": "4048", "DedeUserID": "520271156",
35
+ "DedeUserID__ckMd5": "66450f2302095cc5", "nostalgia_conf": "-1", "rpdid": "|(JY))RmR~|u0J'uY~YkuJ~Ru", "buvid_fp_plain": "undefined", "b_ut": "5",
36
+ "hit-dyn-v2": "1", "LIVE_BUVID": "AUTO8716766313471956", "hit-new-style-dyn": "1", "CURRENT_PID": "418c8490-cadb-11ed-b23b-dd640f2e1c14",
37
+ "FEED_LIVE_VERSION": "V8", "header_theme_version": "CLOSE", "CURRENT_QUALITY": "80", "enable_web_push": "DISABLE", "buvid_fp": "52ad4773acad74caefdb23875d5217cd",
38
+ "PVID": "1", "home_feed_column": "5", "SESSDATA": "8036f42c%2C1719895843%2C19675%2A12CjATThdxG8TyQ2panBpBQcmT0gDKjexwc-zXNGiMnIQ2I9oLVmOiE9YkLao2_aawEhoSVlhGY05PVjVkZWM0T042Z2hZRXBOdElYWXhJa3RpVmZ0M3NvcWw1N0tPcGRVSmRoOVNQZnNHT1JHS05yR1Y1MUFLX3RXeXVJa3NjbEVBQkUxRVN6RFRRIIEC",
39
+ "bili_jct": "4c583b61b86b16d812a7804078828688", "sid": "8dt1ioao", "bili_ticket": "eyJhbGciOiJIUzI1NiIsImtpZCI6InMwMyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDQ2MjUzNjAsImlhdCI6MTcwNDM2NjEwMCwicGx0IjotMX0.4E-V4K2y452cy6eexwY2x_q3-xgcNF2qtugddiuF8d4",
40
+ "bili_ticket_expires": "1704625300", "fingerprint": "847f1839b443252d91ff0df7465fa8d9", "browser_resolution": "1912-924", "bp_video_offset_520271156": "883089613008142344",
41
+ }
42
+ self.default_cookies = self.default_search_cookies
43
+ self.default_download_cookies = self.default_search_cookies
44
+ self._initsession()
45
+ '''_constructsearchurls'''
46
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
47
+ # init
48
+ rule, request_overrides = rule or {}, request_overrides or {}
49
+ # search rules
50
+ default_rule = {
51
+ '__refresh__': 'true', '_extra': '', 'page': 1, 'page_size': self.search_size_per_page, 'platform': 'pc', 'highlight': '1',
52
+ 'context': '', 'single_column': '0', 'keyword': keyword, 'category_id': '', 'search_type': 'video', 'dynamic_offset': '0',
53
+ 'preload': 'true', 'com2co': 'true'
54
+ }
55
+ default_rule.update(rule)
56
+ # construct search urls based on search rules
57
+ base_url = 'https://api.bilibili.com/x/web-interface/search/type?'
58
+ search_urls, page_size, count = [], self.search_size_per_page, 0
59
+ while self.search_size_per_source > count:
60
+ page_rule = copy.deepcopy(default_rule)
61
+ page_rule['page_size'] = page_size
62
+ page_rule['page'] = int(count // page_size) + 1
63
+ search_urls.append(base_url + urlencode(page_rule))
64
+ count += page_size
65
+ # return
66
+ return search_urls
67
+ '''_search'''
68
+ @usesearchheaderscookies
69
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
70
+ # init
71
+ request_overrides = request_overrides or {}
72
+ # successful
73
+ try:
74
+ # --search results
75
+ resp = self.get(search_url, **request_overrides)
76
+ resp.raise_for_status()
77
+ search_results = resp2json(resp)['data']['result']
78
+ for search_result in search_results:
79
+ # --download results
80
+ if not isinstance(search_result, dict) or ('id' not in search_result) or ('bvid' not in search_result):
81
+ continue
82
+ song_info = SongInfo(source=self.source)
83
+ try:
84
+ resp = self.get(f"https://api.bilibili.com/x/web-interface/view?bvid={search_result['bvid']}", **request_overrides)
85
+ resp.raise_for_status()
86
+ pages = resp2json(resp=resp)['data']['pages']
87
+ episodes = [(page["cid"], page["part"]) for page in pages] # we only pick the first one
88
+ cid, episode_name = episodes[0]
89
+ resp = self.get(f"https://api.bilibili.com/x/player/playurl?fnval=16&bvid={search_result['bvid']}&cid={cid}")
90
+ download_result = resp2json(resp=resp)
91
+ audios_sorted = sorted(download_result["data"]["dash"]["audio"], key=lambda x: x.get("bandwidth", 0), reverse=True)
92
+ except:
93
+ continue
94
+ download_url = audios_sorted[0].get('baseUrl') or audios_sorted[0].get('base_url') or audios_sorted[0].get('backupUrl') or audios_sorted[0].get('backup_url')
95
+ if not download_url: continue
96
+ if isinstance(download_url, list): download_url = download_url[0]
97
+ song_info = SongInfo(
98
+ source=self.source, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
99
+ ext='m4a', raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, lyric='NULL', duration_s=download_result["data"]["dash"].get('duration', 0),
100
+ duration=seconds2hms(download_result["data"]["dash"].get('duration', 0)), file_size='NULL', song_name=legalizestring(episode_name, replace_null_string='NULL'),
101
+ singers=legalizestring(safeextractfromdict(search_result, ['author'], ""), replace_null_string='NULL'), album='NULL', identifier=f"{search_result['bvid']}_{cid}"
102
+ )
103
+ if not song_info.with_valid_download_url: continue
104
+ song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(download_url, request_overrides)
105
+ ext, file_size = song_info.download_url_status['probe_status']['ext'], song_info.download_url_status['probe_status']['file_size']
106
+ if file_size and file_size != 'NULL': song_info.file_size = file_size
107
+ if ext and ext != 'NULL': song_info.ext = ext
108
+ # --append to song_infos
109
+ song_infos.append(song_info)
110
+ # --judgement for search_size
111
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
112
+ # --update progress
113
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
114
+ # failure
115
+ except Exception as err:
116
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
117
+ # return
118
+ return song_infos
@@ -0,0 +1,148 @@
1
+ '''
2
+ Function:
3
+ Implementation of BuguyyMusicClient: https://buguyy.top/
4
+ Author:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
+ Charles的皮卡丘
8
+ '''
9
+ import re
10
+ import copy
11
+ from .base import BaseMusicClient
12
+ from urllib.parse import urlencode
13
+ from rich.progress import Progress
14
+ from ..utils import legalizestring, usesearchheaderscookies, resp2json, safeextractfromdict, SongInfo, QuarkParser
15
+
16
+
17
+ '''BuguyyMusicClient'''
18
+ class BuguyyMusicClient(BaseMusicClient):
19
+ source = 'BuguyyMusicClient'
20
+ def __init__(self, **kwargs):
21
+ super(BuguyyMusicClient, self).__init__(**kwargs)
22
+ 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.')
23
+ self.default_search_headers = {
24
+ "accept": "application/json, text/plain, */*",
25
+ "accept-encoding": "gzip, deflate, br, zstd",
26
+ "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
27
+ "origin": "https://buguyy.top",
28
+ "priority": "u=1, i",
29
+ "referer": "https://buguyy.top/",
30
+ "sec-ch-ua": "\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"",
31
+ "sec-ch-ua-mobile": "?0",
32
+ "sec-ch-ua-platform": "\"Windows\"",
33
+ "sec-fetch-dest": "empty",
34
+ "sec-fetch-mode": "cors",
35
+ "sec-fetch-site": "same-site",
36
+ "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",
37
+ }
38
+ self.default_download_headers = {
39
+ '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',
40
+ }
41
+ self.default_headers = self.default_search_headers
42
+ self._initsession()
43
+ '''_constructsearchurls'''
44
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
45
+ # init
46
+ rule, request_overrides = rule or {}, request_overrides or {}
47
+ # search rules
48
+ default_rule = {'keyword': keyword}
49
+ default_rule.update(rule)
50
+ # construct search urls based on search rules
51
+ base_url = 'https://a.buguyy.top/newapi/search.php?'
52
+ page_rule = copy.deepcopy(default_rule)
53
+ search_urls = [base_url + urlencode(page_rule)]
54
+ self.search_size_per_page = self.search_size_per_source
55
+ # return
56
+ return search_urls
57
+ '''_search'''
58
+ @usesearchheaderscookies
59
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
60
+ # init
61
+ request_overrides = request_overrides or {}
62
+ # successful
63
+ try:
64
+ # --search results
65
+ resp = self.get(search_url, verify=False, **request_overrides)
66
+ resp.raise_for_status()
67
+ search_results = resp2json(resp=resp)['data']['list']
68
+ for search_result in search_results:
69
+ # --download results
70
+ if not isinstance(search_result, dict) or ('id' not in search_result):
71
+ continue
72
+ song_info = SongInfo(source=self.source)
73
+ # ----parse from quark links
74
+ if self.quark_parser_config.get('cookies'):
75
+ quark_download_urls = [search_result.get('downurl', ''), search_result.get('ktmdownurl', '')]
76
+ for quark_download_url in quark_download_urls:
77
+ song_info = SongInfo(source=self.source)
78
+ try:
79
+ m = re.search(r"WAV#(https?://[^#]+)", quark_download_url)
80
+ quark_wav_download_url = m.group(1)
81
+ download_result, download_url = QuarkParser.parsefromurl(quark_wav_download_url, **self.quark_parser_config)
82
+ if not download_url: continue
83
+ download_url_status = self.quark_audio_link_tester.test(download_url, request_overrides)
84
+ download_url_status['probe_status'] = self.quark_audio_link_tester.probe(download_url, request_overrides)
85
+ ext = download_url_status['probe_status']['ext']
86
+ if ext == 'NULL': ext = 'wav'
87
+ song_info.update(dict(
88
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
89
+ default_download_headers=self.quark_default_download_headers, ext=ext, file_size=download_url_status['probe_status']['file_size']
90
+ ))
91
+ if song_info.with_valid_download_url: break
92
+ except:
93
+ continue
94
+ # ----parse from play url
95
+ lyric_result = {}
96
+ if not song_info.with_valid_download_url:
97
+ song_info = SongInfo(source=self.source)
98
+ try:
99
+ resp = self.get(f'https://a.buguyy.top/newapi/geturl2.php?id={search_result["id"]}', verify=False, **request_overrides)
100
+ resp.raise_for_status()
101
+ download_result = resp2json(resp=resp)
102
+ download_url = safeextractfromdict(download_result, ['data', 'url'], '')
103
+ if not download_url: continue
104
+ download_url_status = self.audio_link_tester.test(download_url, request_overrides)
105
+ download_url_status['probe_status'] = self.audio_link_tester.probe(download_url, request_overrides)
106
+ ext = download_url_status['probe_status']['ext']
107
+ if ext == 'NULL': download_url.split('.')[-1].split('?')[0] or 'mp3'
108
+ song_info.update(dict(
109
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
110
+ ext=ext, file_size=download_url_status['probe_status']['file_size']
111
+ ))
112
+ except:
113
+ continue
114
+ lyric_result = copy.deepcopy(download_result)
115
+ else:
116
+ try:
117
+ resp = self.get(f'https://a.buguyy.top/newapi/geturl2.php?id={search_result["id"]}', verify=False, **request_overrides)
118
+ resp.raise_for_status()
119
+ lyric_result = resp2json(resp=resp)
120
+ except:
121
+ pass
122
+ if not song_info.with_valid_download_url: continue
123
+ # ----parse more infos
124
+ try:
125
+ duration = '{:02d}:{:02d}:{:02d}'.format(*([0,0,0] + list(map(int, re.findall(r'\d+', safeextractfromdict(lyric_result, ['data', 'duration'], '')))))[-3:])
126
+ if duration == '00:00:00': duration = '-:-:-'
127
+ except:
128
+ duration = '-:-:-'
129
+ lyric = safeextractfromdict(lyric_result, ['data', 'lrc'], '')
130
+ if not lyric or '歌词获取失败' in lyric: lyric = 'NULL'
131
+ song_info.raw_data['lyric'] = lyric_result
132
+ song_info.update(dict(
133
+ lyric=lyric, duration=duration, song_name=legalizestring(search_result.get('title', 'NULL'), replace_null_string='NULL'),
134
+ singers=legalizestring(search_result.get('singer', 'NULL'), replace_null_string='NULL'),
135
+ album=legalizestring(safeextractfromdict(lyric_result, ['data', 'album'], ''), replace_null_string='NULL'),
136
+ identifier=search_result['id'],
137
+ ))
138
+ # --append to song_infos
139
+ song_infos.append(song_info)
140
+ # --judgement for search_size
141
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
142
+ # --update progress
143
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
144
+ # failure
145
+ except Exception as err:
146
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
147
+ # return
148
+ return song_infos
@@ -0,0 +1,153 @@
1
+ '''
2
+ Function:
3
+ Implementation of FangpiMusicClient: https://www.fangpi.net/
4
+ Author:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
+ Charles的皮卡丘
8
+ '''
9
+ import re
10
+ import json_repair
11
+ from bs4 import BeautifulSoup
12
+ from urllib.parse import urljoin
13
+ from .base import BaseMusicClient
14
+ from rich.progress import Progress
15
+ from ..utils import legalizestring, usesearchheaderscookies, resp2json, safeextractfromdict, SongInfo, QuarkParser
16
+
17
+
18
+ '''FangpiMusicClient'''
19
+ class FangpiMusicClient(BaseMusicClient):
20
+ source = 'FangpiMusicClient'
21
+ def __init__(self, **kwargs):
22
+ super(FangpiMusicClient, self).__init__(**kwargs)
23
+ 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.')
24
+ self.default_search_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',
26
+ }
27
+ self.default_download_headers = {
28
+ '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',
29
+ }
30
+ self.default_headers = self.default_search_headers
31
+ self._initsession()
32
+ '''_constructsearchurls'''
33
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
34
+ # init
35
+ rule, request_overrides = rule or {}, request_overrides or {}
36
+ # construct search urls
37
+ search_urls = [f'https://www.fangpi.net/s/{keyword}']
38
+ self.search_size_per_page = self.search_size_per_source
39
+ # return
40
+ return search_urls
41
+ '''_parsesearchresultsfromhtml'''
42
+ def _parsesearchresultsfromhtml(self, html_text: str):
43
+ soup = BeautifulSoup(html_text, "lxml")
44
+ search_results, base_url = [], "https://www.fangpi.net"
45
+ for row in soup.select('div.card.mb-1 div.card-text > div.row'):
46
+ link = row.select_one('a.music-link')
47
+ if not link: continue
48
+ href = link.get('href', '').strip()
49
+ if not href: continue
50
+ url = urljoin(base_url, href)
51
+ title_span = link.select_one('.music-title span')
52
+ if title_span: title = title_span.get_text(strip=True)
53
+ else: title = link.get_text(strip=True)
54
+ artist_tag = link.find('small')
55
+ artist = artist_tag.get_text(strip=True) if artist_tag else ""
56
+ search_results.append({"title": title, "artist": artist, "url": url})
57
+ return search_results
58
+ '''_search'''
59
+ @usesearchheaderscookies
60
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
61
+ # init
62
+ request_overrides = request_overrides or {}
63
+ # successful
64
+ try:
65
+ # --search results
66
+ resp = self.get(search_url, **request_overrides)
67
+ resp.raise_for_status()
68
+ search_results = self._parsesearchresultsfromhtml(resp.text)
69
+ for search_result in search_results:
70
+ # --download results
71
+ if not isinstance(search_result, dict) or ('url' not in search_result):
72
+ continue
73
+ song_info = SongInfo(source=self.source)
74
+ # ----fetch basic information
75
+ try:
76
+ resp = self.get(search_result['url'], **request_overrides)
77
+ resp.raise_for_status()
78
+ soup = BeautifulSoup(resp.text, "lxml")
79
+ script_tag = soup.find("script", string=re.compile(r"window\.appData"))
80
+ if script_tag is None: continue
81
+ js_text: str = script_tag.string
82
+ m = re.search(r"window\.appData\s*=\s*(\{.*?\})\s*;", js_text, re.S)
83
+ if not m: continue
84
+ download_result = json_repair.loads(m.group(1))
85
+ except:
86
+ continue
87
+ # ----parse from quark links
88
+ if self.quark_parser_config.get('cookies'):
89
+ quark_download_urls = download_result.get('mp3_extra_urls', [])
90
+ for quark_download_url in quark_download_urls:
91
+ song_info = SongInfo(source=self.source)
92
+ try:
93
+ quark_wav_download_url = quark_download_url['share_link']
94
+ download_result['quark_parse_result'], download_url = QuarkParser.parsefromurl(quark_wav_download_url, **self.quark_parser_config)
95
+ if not download_url: continue
96
+ download_url_status = self.quark_audio_link_tester.test(download_url, request_overrides)
97
+ download_url_status['probe_status'] = self.quark_audio_link_tester.probe(download_url, request_overrides)
98
+ ext = download_url_status['probe_status']['ext']
99
+ if ext == 'NULL': ext = 'mp3'
100
+ song_info.update(dict(
101
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
102
+ default_download_headers=self.quark_default_download_headers, ext=ext, file_size=download_url_status['probe_status']['file_size']
103
+ ))
104
+ if song_info.with_valid_download_url: break
105
+ except:
106
+ continue
107
+ # ----parse from play url
108
+ if not song_info.with_valid_download_url:
109
+ if 'play_id' not in download_result or not download_result['play_id']: continue
110
+ song_info = SongInfo(source=self.source)
111
+ try:
112
+ resp = self.post('https://www.fangpi.net/api/play-url', json={'id': download_result['play_id']}, **request_overrides)
113
+ resp.raise_for_status()
114
+ download_result['api/play-url'] = resp2json(resp=resp)
115
+ download_url = safeextractfromdict(download_result['api/play-url'], ['data', 'url'], '')
116
+ if not download_url: continue
117
+ download_url_status = self.audio_link_tester.test(download_url, request_overrides)
118
+ download_url_status['probe_status'] = self.audio_link_tester.probe(download_url, request_overrides)
119
+ ext = download_url_status['probe_status']['ext']
120
+ if ext == 'NULL': download_url.split('.')[-1].split('?')[0] or 'mp3'
121
+ song_info.update(dict(
122
+ download_url=download_url, download_url_status=download_url_status, raw_data={'search': search_result, 'download': download_result},
123
+ ext=ext, file_size=download_url_status['probe_status']['file_size']
124
+ ))
125
+ except:
126
+ continue
127
+ if not song_info.with_valid_download_url: continue
128
+ # ----parse more infos
129
+ try:
130
+ lrc_div = soup.find("div", id="content-lrc")
131
+ lyric, lyric_result = lrc_div.get_text("\n", strip=True), {'lrc_div': str(lrc_div)}
132
+ except:
133
+ lyric, lyric_result = 'NULL', {}
134
+ format_duration = lambda d: "{:02}:{:02}:{:02}".format(*([0] * (3 - len(d.split(":"))) + list(map(int, d.split(":")))))
135
+ duration = format_duration(download_result.get('mp3_duration', '00:00:00') or '00:00:00')
136
+ if duration == '00:00:00': duration = '-:-:-'
137
+ song_info.raw_data['lyric'] = lyric_result
138
+ song_info.update(dict(
139
+ lyric=lyric, duration=duration, song_name=legalizestring(download_result.get('mp3_title', 'NULL'), replace_null_string='NULL'),
140
+ singers=legalizestring(download_result.get('mp3_author', 'NULL'), replace_null_string='NULL'), album='NULL',
141
+ identifier=download_result.get('play_id') or song_info.download_url,
142
+ ))
143
+ # --append to song_infos
144
+ song_infos.append(song_info)
145
+ # --judgement for search_size
146
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
147
+ # --update progress
148
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
149
+ # failure
150
+ except Exception as err:
151
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
152
+ # return
153
+ return song_infos
@@ -0,0 +1,108 @@
1
+ '''
2
+ Function:
3
+ Implementation of FiveSingMusicClient: https://5sing.kugou.com/index.html
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, byte2mb, resp2json, usesearchheaderscookies, SongInfo
14
+
15
+
16
+ '''FiveSingMusicClient'''
17
+ class FiveSingMusicClient(BaseMusicClient):
18
+ source = 'FiveSingMusicClient'
19
+ def __init__(self, **kwargs):
20
+ super(FiveSingMusicClient, 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',
23
+ }
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',
26
+ }
27
+ self.default_headers = self.default_search_headers
28
+ self._initsession()
29
+ '''_constructsearchurls'''
30
+ def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
31
+ # init
32
+ rule, request_overrides = rule or {}, request_overrides or {}
33
+ # search rules
34
+ default_rule = {'keyword': keyword, 'sort': 1, 'page': 1, 'filter': 0, 'type': 0}
35
+ default_rule.update(rule)
36
+ # construct search urls based on search rules
37
+ base_url = 'http://search.5sing.kugou.com/home/json?'
38
+ search_urls, page_size, count = [], self.search_size_per_page, 0
39
+ while self.search_size_per_source > count:
40
+ page_rule = copy.deepcopy(default_rule)
41
+ page_rule['page'] = int(count // page_size) + 1
42
+ search_urls.append(base_url + urlencode(page_rule))
43
+ count += page_size
44
+ # return
45
+ return search_urls
46
+ '''_search'''
47
+ @usesearchheaderscookies
48
+ def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
49
+ # init
50
+ request_overrides = request_overrides or {}
51
+ # successful
52
+ try:
53
+ # --search results
54
+ resp = self.get(search_url, **request_overrides)
55
+ resp.raise_for_status()
56
+ search_results = resp2json(resp)['list']
57
+ for search_result in search_results:
58
+ # --download results
59
+ if not isinstance(search_result, dict) or ('songId' not in search_result) or ('typeEname' not in search_result):
60
+ continue
61
+ song_info = SongInfo(source=self.source)
62
+ params = {'songid': str(search_result['songId']), 'songtype': search_result['typeEname']}
63
+ try:
64
+ resp = self.get('http://mobileapi.5sing.kugou.com/song/getSongUrl', params=params, **request_overrides)
65
+ resp.raise_for_status()
66
+ download_result: dict = resp2json(resp)
67
+ if download_result['code'] not in [1000, '1000']: continue
68
+ except:
69
+ continue
70
+ data: dict = download_result.get('data', {})
71
+ for quality in ['sq', 'hq', 'lq']:
72
+ download_url = data.get(f'{quality}url', '').strip() or data.get(f'{quality}url_backup', '').strip()
73
+ if not download_url: continue
74
+ song_info = SongInfo(
75
+ source=self.source, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
76
+ ext = data.get(f'{quality}ext', 'mp3').strip() or 'mp3', file_size_bytes=data.get(f'{quality}size', 0),
77
+ file_size = byte2mb(data.get(f'{quality}size', 0)), raw_data={'search': search_result, 'download': download_result},
78
+ )
79
+ if song_info.with_valid_download_url: break
80
+ if not song_info.with_valid_download_url: continue
81
+ # --lyric results
82
+ params = {'songid': str(search_result['songId']), 'songtype': search_result['typeEname'], 'songfields': '', 'userfields': ''}
83
+ try:
84
+ resp = self.get('http://mobileapi.5sing.kugou.com/song/newget', params=params, **request_overrides)
85
+ resp.raise_for_status()
86
+ lyric_result: dict = resp2json(resp)
87
+ lyric = str(lyric_result.get('data', {}).get('dynamicWords', 'NULL')).strip() or 'NULL'
88
+ except:
89
+ lyric_result, lyric = {}, 'NULL'
90
+ # --update song_info
91
+ song_info.raw_data['lyric'] = lyric_result
92
+ song_info.update(dict(
93
+ song_name=legalizestring(search_result.get('songName', 'NULL'), replace_null_string='NULL'),
94
+ singers=legalizestring(search_result.get('singer', 'NULL'), replace_null_string='NULL'),
95
+ album=legalizestring(lyric_result.get('data', {}).get('albumName', 'NULL'), replace_null_string='NULL'),
96
+ identifier=search_result['songId'], lyric=lyric, duration='-:-:-',
97
+ ))
98
+ # --append to song_infos
99
+ song_infos.append(song_info)
100
+ # --judgement for search_size
101
+ if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
102
+ # --update progress
103
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
104
+ # failure
105
+ except Exception as err:
106
+ progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
107
+ # return
108
+ return song_infos