Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
@@ -0,0 +1,6 @@
'''initialize'''
from .jbsou import JBSouMusicClient
from .tunehub import TuneHubMusicClient
from .mp3juice import MP3JuiceMusicClient
from .gdstudio import GDStudioMusicClient
from .myfreemp3 import MyFreeMP3MusicClient
@@ -0,0 +1,166 @@
'''
Function:
Implementation of GDStudioMusicClient: https://music.gdstudio.xyz/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import copy
import time
import random
import hashlib
import json_repair
from urllib.parse import quote
from rich.progress import Progress
from ..sources import BaseMusicClient
from ..utils import legalizestring, resp2json, usesearchheaderscookies, byte2mb, estimatedurationwithfilesizebr, estimatedurationwithfilelink, seconds2hms, safeextractfromdict, cleanlrc, SongInfo, AudioLinkTester
'''GDStudioMusicClient'''
class GDStudioMusicClient(BaseMusicClient):
source = 'GDStudioMusicClient'
SUPPORTED_SITES = ['spotify', 'netease', 'kuwo', 'tidal', 'qobuz', 'joox', 'bilibili', 'apple', 'tencent', 'ytmusic'] # 'kugou', 'ximalaya', 'migu'
SITE_TO_API_MAPPER = {
'netease': 'https://music.gdstudio.xyz/api.php', 'tencent': 'https://music.gdstudio.xyz/api.php', 'tidal': 'https://music.gdstudio.xyz/api.php', 'spotify': 'https://music.gdstudio.xyz/api.php', 'kuwo': 'https://music.gdstudio.xyz/api.php', 'bilibili': 'https://music.gdstudio.xyz/api.php', 'apple': 'https://music.gdstudio.xyz/api.php',
'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', 'joox': 'https://music-api-hk.gdstudio.xyz/api.php', 'qobuz': 'https://music-api-us.gdstudio.xyz/api.php', 'ytmusic': 'https://music-api-us.gdstudio.xyz/api.php',
}
def __init__(self, **kwargs):
self.allowed_music_sources = list(set(kwargs.pop('allowed_music_sources', GDStudioMusicClient.SUPPORTED_SITES[:-2])))
super(GDStudioMusicClient, self).__init__(**kwargs)
self.default_search_headers = {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', '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'}
self.default_download_headers = {'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'}
self.default_headers = self.default_search_headers
self._initsession()
'''_yieldcallback'''
def _yieldcallback(self):
random_num = ''.join([str(random.randint(0, 9)) for _ in range(21)])
timestamp = int(time.time() * 1000)
return f"jQuery{random_num}_{timestamp}"
'''_yieldcrc32'''
def _yieldcrc32(self, id_value: str, hostname: str = 'music.gdstudio.xyz', version: str = "2025.11.4"):
# timestamp
try: (resp := self.get('https://www.ximalaya.com/revision/time')).raise_for_status(); ts_ms = resp.text.strip()
except Exception: ts_ms = int(time.time() * 1000)
ts9 = str(ts_ms)[:9]
# version
parts = version.split("."); padded = [p if len(p) != 1 else "0" + p for p in parts]; ver_padded = "".join(padded)
# id
id_str = quote(str(id_value))
# src
src = f"{hostname}|{ver_padded}|{ts9}|{id_str}"
# return
return hashlib.md5(src.encode("utf-8")).hexdigest()[-8:].upper()
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
allowed_music_sources = copy.deepcopy(self.allowed_music_sources)
# search rules
default_rule = {'types': 'search', 'count': self.search_size_per_page, 'pages': '1', 'name': keyword}
default_rule.update(rule)
# construct search urls based on search rules
search_urls, page_size = [], self.search_size_per_page
for source in GDStudioMusicClient.SUPPORTED_SITES:
if source not in allowed_music_sources: continue
source_default_rule = copy.deepcopy(default_rule)
source_default_rule['source'], count = source, 0
while self.search_size_per_source > count:
if GDStudioMusicClient.SITE_TO_API_MAPPER[source] in {'https://music.gdstudio.xyz/api.php'}:
page_rule_post = copy.deepcopy(source_default_rule)
page_rule_post['pages'] = str(int(count // page_size) + 1); page_rule_post['count'] = str(page_size); page_rule_post['s'] = self._yieldcrc32(keyword)
search_urls.append({'url': GDStudioMusicClient.SITE_TO_API_MAPPER[source], 'data': page_rule_post, 'params': {'callback': self._yieldcallback()}, 'method': 'post'})
else:
page_rule_get = copy.deepcopy(source_default_rule)
page_rule_get['pages'] = str(int(count // page_size) + 1); page_rule_get['count'] = str(page_size); page_rule_get['s'] = self._yieldcrc32(keyword); page_rule_get['callback'] = self._yieldcallback(); page_rule_get['_'] = str(int(time.time() * 1000))
search_urls.append({'url': GDStudioMusicClient.SITE_TO_API_MAPPER[source], 'params': page_rule_get, 'method': 'get'})
count += page_size
# return
return search_urls
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
search_meta = copy.deepcopy(search_url)
search_url, method = search_meta.pop('url'), search_meta.pop('method')
self.default_headers, request_overrides = copy.deepcopy(self.default_headers), copy.deepcopy(request_overrides)
# successful
try:
# --search results
(resp := getattr(self, method)(search_url, **search_meta, **request_overrides)).raise_for_status()
search_results = json_repair.loads(resp.text[resp.text.index('(')+1: resp.text.rindex(')')])
for search_result in search_results:
# --download results
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
song_info, song_id = SongInfo(source=self.source, root_source=search_result['source']), search_result['id']
for br in [999, 740, 320, 192, 128]: # 999 and 740 mean lossless
params = {'callback': self._yieldcallback()}; data_json = {'types': 'url', 'id': song_id, 'source': search_result['source'], 'br': br, 's': self._yieldcrc32(song_id)}
try: (resp := self.post(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], params=params, data=data_json, **request_overrides)).raise_for_status() if method == 'post' else (resp := self.get(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], params={**params, **data_json, '_': str(int(time.time() * 1000))}, **request_overrides)).raise_for_status()
except Exception: continue
download_result = json_repair.loads(resp.text[resp.text.index('(')+1: resp.text.rindex(')')])
if not (download_url := download_result.get('url')): continue
if not str(download_url).startswith('http'): download_url = f'https://music.gdstudio.xyz/' + download_url
if search_result['source'] in {'bilibili'}: download_url = f'https://music-proxy.gdstudio.org/{download_url}'
download_url_status = self.audio_link_tester.test(download_url, request_overrides); download_url = download_url_status['final_url']
duration_in_secs = estimatedurationwithfilesizebr(download_result.get('size', 0), download_result.get('br', br), return_seconds=True)
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(search_result, ['name'], None)), singers=legalizestring(', '.join(safeextractfromdict(search_result, ['artist'], []) or [])),
album=legalizestring(safeextractfromdict(search_result, ['album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=download_result.get('size'), file_size=byte2mb(download_result.get('size', 0)), identifier=song_id, duration_s=duration_in_secs,
duration=seconds2hms(duration_in_secs), lyric=None, cover_url=None, download_url=download_url, download_url_status=download_url_status, root_source=search_result['source'],
)
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
if song_info.with_valid_download_url: break
if not song_info.with_valid_download_url: continue
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']
if song_info.ext in {'m4s', 'mp4'}: song_info.ext = 'm4a'
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
# --lyric results
try:
data_json = {'types': 'lyric', 'id': search_result['lyric_id'], 'source': search_result['source'], 's': self._yieldcrc32(search_result['lyric_id'])}
if method == 'post': (resp := self.post(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], data=data_json, params={'callback': self._yieldcallback()}, **request_overrides)).raise_for_status()
else: (resp := self.get(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], params={**{'callback': self._yieldcallback()}, **data_json, '_': str(int(time.time() * 1000))}, **request_overrides)).raise_for_status()
lyric_result = json_repair.loads(resp.text[resp.text.index('(')+1: resp.text.rindex(')')])
lyric = cleanlrc(lyric_result.get('lyric') or "") or cleanlrc(lyric_result.get('tlyric') or "") or 'NULL'
except:
lyric_result, lyric = dict(), 'NULL'
if not lyric or lyric == 'NULL':
try:
params = {'artist_name': song_info.singers, 'track_name': song_info.song_name, 'album_name': song_info.album, 'duration': estimatedurationwithfilelink(song_info.download_url, headers=self.default_download_headers, request_overrides=request_overrides)}
(resp := self.get(f'https://lrclib.net/api/get?', params=params, **request_overrides)).raise_for_status()
lyric_result = resp2json(resp=resp); lyric = cleanlrc(lyric_result.get('syncedLyrics') or "") or 'NULL'
song_info.duration_s, song_info.duration = params['duration'], seconds2hms(params['duration'])
except:
lyric_result, lyric = dict(), 'NULL'
song_info.raw_data['lyric'] = lyric_result if lyric_result else song_info.raw_data['lyric']
song_info.lyric = lyric if (lyric and (lyric not in {'NULL'})) else song_info.lyric
# --cover results
if search_result['source'] in {'kuwo'}:
cdn_hosts = ["http://img1.kwcdn.kuwo.cn/star/albumcover/", "http://img2.kwcdn.kuwo.cn/star/albumcover/", "http://img3.kwcdn.kuwo.cn/star/albumcover/"]
try: search_result['pic_id'] = '300/' + search_result['pic_id'][4:] if str(search_result['pic_id']).startswith('120/') else search_result['pic_id']; song_info.cover_url = cdn_hosts[0] + search_result['pic_id']
except Exception: pass
elif search_result['source'] in {'apple'}:
try: song_info.cover_url = search_result['pic_id'].format(w=300, h=300)
except Exception: pass
elif search_result['source'] in {'bilibili'}:
try: song_info.cover_url = search_result['pic_id']; song_info.cover_url = f'https:{song_info.cover_url}' if not song_info.cover_url.startswith('http') else song_info.cover_url
except Exception: pass
else:
try:
data_json = {'types': 'pic', 'id': search_result['pic_id'], 'source': search_result['source'], 'size': 300, 's': self._yieldcrc32(search_result['pic_id'])}
(resp := self.post(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], data=data_json, params={'callback': self._yieldcallback()}, **request_overrides)).raise_for_status() if method == 'post' else (resp := self.get(GDStudioMusicClient.SITE_TO_API_MAPPER[search_result['source']], params={**{'callback': self._yieldcallback()}, **data_json, '_': str(int(time.time() * 1000))}, **request_overrides)).raise_for_status()
cover_result = json_repair.loads(resp.text[resp.text.index('(')+1: resp.text.rindex(')')]); song_info.cover_url = cover_result['url']
except Exception: pass
# --append to song_infos
song_infos.append(song_info)
# --judgement for search_size
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
# --update progress
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
# failure
except Exception as err:
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
# return
return song_infos
@@ -0,0 +1,98 @@
'''
Function:
Implementation of JBSouMusicClient: https://www.jbsou.cn/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import copy
from urllib.parse import urljoin
from rich.progress import Progress
from ..sources import BaseMusicClient
from ..utils import legalizestring, resp2json, usesearchheaderscookies, seconds2hms, extractdurationsecondsfromlrc, safeextractfromdict, cleanlrc, SongInfo, AudioLinkTester
'''JBSouMusicClient'''
class JBSouMusicClient(BaseMusicClient):
source = 'JBSouMusicClient'
ALLOWED_SITES = ['netease', 'qq', 'kugou', 'kuwo', 'migu', 'qianqian'][:-2] # it seems qianqian and migu are useless, recorded in 2026-01-29
def __init__(self, **kwargs):
self.allowed_music_sources = list(set(kwargs.pop('allowed_music_sources', JBSouMusicClient.ALLOWED_SITES)))
super(JBSouMusicClient, self).__init__(**kwargs)
self.default_search_headers = {
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "origin": "https://www.jbsou.cn", "x-requested-with": "XMLHttpRequest",
"accept": "application/json, text/javascript, */*; q=0.01", "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "referer": "https://www.jbsou.cn/"
}
self.default_download_headers = {
'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',
}
self.default_headers = self.default_search_headers
self._initsession()
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
self.search_size_per_page = min(self.search_size_per_page, 10)
allowed_music_sources = copy.deepcopy(self.allowed_music_sources)
# construct search urls based on search rules
base_url = 'https://www.jbsou.cn/'
search_urls, page_size = [], self.search_size_per_page
for source in JBSouMusicClient.ALLOWED_SITES:
if source not in allowed_music_sources: continue
source_default_rule, count = {'input': keyword, 'filter': 'name', 'type': source, 'page': 1}, 0
source_default_rule.update(rule)
while self.search_size_per_source > count:
page_rule = copy.deepcopy(source_default_rule)
page_rule['page'] = str(int(count // page_size) + 1)
search_urls.append({'url': base_url, 'data': page_rule})
count += page_size
# return
return search_urls
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides, base_url = request_overrides or {}, "https://www.jbsou.cn/"
source = search_url['data']['type']
# successful
try:
# --search results
(resp := self.post(**search_url, **request_overrides)).raise_for_status()
search_results = resp2json(resp)['data']
for search_result in search_results:
# --download results
if not isinstance(search_result, dict) or ('songid' not in search_result) or ('url' not in search_result): continue
search_result['source'] = source; song_info = SongInfo(source=self.source, root_source=search_result['source'])
download_url = urljoin(base_url, search_result['url'])
try: (resp := self.session.head(download_url, allow_redirects=True, **request_overrides)).raise_for_status(); download_url = resp.url
except Exception: continue
cover_url = urljoin(base_url, search_result.get('cover', "") or "")
try: (resp := self.session.head(cover_url, timeout=10, allow_redirects=True, **request_overrides)).raise_for_status(); cover_url = resp.url
except Exception: cover_url = cover_url
song_info = SongInfo(
raw_data={'search': search_result, 'download': {}, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(str(safeextractfromdict(search_result, ['artist'], "")).replace('/', ', ')),
album=legalizestring(search_result.get('album')), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=search_result['songid'], duration_s=None, duration='-:-:-', lyric=None, cover_url=cover_url,
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), root_source=search_result['source'],
)
if not song_info.with_valid_download_url: continue
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
# --lyric results
try: (resp := self.get(urljoin(base_url, search_result['lrc']), **request_overrides)).raise_for_status(); lyric, lyric_result = cleanlrc(resp.text), {'lyric': resp.text}; song_info.duration_s = extractdurationsecondsfromlrc(lyric); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: lyric_result, lyric = dict(), 'NULL'
song_info.raw_data['lyric'] = lyric_result if lyric_result else song_info.raw_data['lyric']
song_info.lyric = lyric if (lyric and (lyric not in {'NULL'})) else song_info.lyric
# --append to song_infos
song_infos.append(song_info)
# --judgement for search_size
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
# --update progress
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
# failure
except Exception as err:
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
# return
return song_infos
@@ -0,0 +1,119 @@
'''
Function:
Implementation of MP3JuiceMusicClient: https://mp3juice.co/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import re
import copy
import time
import base64
import json_repair
from urllib.parse import quote
from itertools import zip_longest
from urllib.parse import urlencode
from rich.progress import Progress
from ..sources import BaseMusicClient
from ..utils import legalizestring, usesearchheaderscookies, resp2json, byte2mb, SongInfo
'''MP3JuiceMusicClient'''
class MP3JuiceMusicClient(BaseMusicClient):
source = 'MP3JuiceMusicClient'
def __init__(self, **kwargs):
kwargs['search_size_per_source'] = kwargs['search_size_per_source'] * 2
super(MP3JuiceMusicClient, self).__init__(**kwargs)
self.default_search_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://mp3juice.sc/", "Origin": "https://mp3juice.sc"}
self.default_download_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://mp3juice.sc/", "Origin": "https://mp3juice.sc"}
self.default_headers = self.default_search_headers
self._initsession()
'''_getdynamicconfig'''
def _getdynamicconfig(self, request_overrides: dict = None):
request_overrides = request_overrides or {}
(resp := self.get(f"https://mp3juice.as/?t={int(time.time() * 1000)}", **request_overrides)).raise_for_status()
match = re.search(r"var\s+json\s*=\s*JSON\.parse\('(.+?)'\);", resp.text)
if not match: match = re.search(r"var\s+json\s*=\s*(\[.+?\]);", resp.text)
return json_repair.loads(match.group(1))
'''_calculateauth'''
def _calculateauth(self, raw_data):
data_arr, should_reverse, offset_arr, result_chars = raw_data[0], raw_data[1], raw_data[2], []; offset_len = len(offset_arr)
for t in range(len(data_arr)): result_chars.append(chr(data_arr[t] - offset_arr[offset_len - (t + 1)]))
if should_reverse: result_chars.reverse()
full_token = "".join(result_chars)
return full_token[:32]
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
config = self._getdynamicconfig(); auth_token = self._calculateauth(config)
# search rules
default_rule = {'k': auth_token, 'y': 's', 'q': base64.b64encode(quote(keyword, safe="").encode("utf-8")).decode("utf-8"), 't': str(int(time.time()))}
default_rule.update(rule)
# construct search urls based on search rules
base_url = 'https://mp3juice.sc/api/v1/search?'
page_rule = copy.deepcopy(default_rule)
search_urls = [{'url': base_url + urlencode(page_rule), 'auth_token': auth_token, 'param_key': chr(config[6])}]
self.search_size_per_page = self.search_size_per_source
# return
return search_urls
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides, search_meta = request_overrides or {}, copy.deepcopy(search_url)
search_url, auth_token, param_key = search_meta['url'], search_meta['auth_token'], search_meta['param_key']
# successful
try:
# --search results
(resp := self.get(search_url, allow_redirects=True, **request_overrides)).raise_for_status()
search_results_yt, search_results_sc = [], []
for item in resp2json(resp)["yt"]: item['root_source'] = 'YouTube'; search_results_yt.append(item)
for item in resp2json(resp)["sc"]: item['root_source'] = 'SoundCloud'; search_results_sc.append(item)
search_results = [x for ab in zip_longest(search_results_yt, search_results_sc) for x in ab if x is not None]
for search_result in search_results:
# --judgement for search_size
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
# --download results
if not isinstance(search_result, dict) or ('id' not in search_result): continue
if search_result['root_source'] in ['SoundCloud'] and ('id_base64' not in search_result or 'title_base64' not in search_result): continue
song_info, download_result = SongInfo(source=self.source, root_source=search_result['root_source']), dict()
# ----SoundCloud
if search_result['root_source'] in ['SoundCloud']:
download_url = f"https://thetacloud.org/s/{search_result['id_base64']}/{search_result['title_base64']}/"
# ----YouTube
else:
params = {param_key: auth_token, 't': str(int(time.time()))}
try: (init_resp := self.get('https://theta.thetacloud.org/api/v1/init?', params=params, **request_overrides)).raise_for_status()
except Exception: continue
download_result['init'] = resp2json(resp=init_resp)
if not (convert_url := download_result['init'].get('convertURL', '')): continue
convert_url = f'{convert_url}&v={search_result["id"]}&f=mp3&t={str(int(time.time()))}'
try: (convert_resp := self.get(convert_url, **request_overrides)).raise_for_status()
except Exception: continue
download_result['convert'] = resp2json(resp=convert_resp)
if not (redirect_url := download_result['convert'].get('redirectURL', '')): continue
try: (resp := self.get(redirect_url, **request_overrides)).raise_for_status()
except Exception: continue
download_result['redirect'] = resp2json(resp=resp)
if not (download_url := download_result['redirect'].get('downloadURL', '')): continue
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers='NULL',
album='NULL', ext='mp3', file_size_bytes=None, file_size=None, identifier=search_result['id'], duration='-:-:-', lyric='NULL', cover_url=None, download_url=download_url,
download_url_status=self.audio_link_tester.test(download_url, request_overrides), root_source=search_result['root_source'],
)
if not song_info.with_valid_download_url: continue
# ----you have to download the music contents immediately, otherwise the links will fail.
song_info.downloaded_contents = self.get(download_url, **request_overrides).content
song_info.file_size_bytes = song_info.downloaded_contents.__sizeof__()
song_info.file_size = byte2mb(song_info.file_size_bytes)
# --append to song_infos
song_infos.append(song_info)
# --update progress
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
# failure
except Exception as err:
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
# return
return song_infos
@@ -0,0 +1,122 @@
'''
Function:
Implementation of MyFreeMP3MusicClient: https://www.myfreemp3.com.cn/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import re
import copy
from urllib.parse import urlparse
from rich.progress import Progress
from ..sources import BaseMusicClient
from ..utils import legalizestring, resp2json, usesearchheaderscookies, seconds2hms, extractdurationsecondsfromlrc, searchdictbykey, cleanlrc, SongInfo, QuarkParser, AudioLinkTester
'''MyFreeMP3MusicClient'''
class MyFreeMP3MusicClient(BaseMusicClient):
source = 'MyFreeMP3MusicClient'
def __init__(self, **kwargs):
super(MyFreeMP3MusicClient, self).__init__(**kwargs)
if not self.quark_parser_config.get('cookies'): self.logger_handle.warning(f'{self.source}.__init__ >>> "quark_parser_config" is not configured, so only "netease" source can be leveraged.')
self.allowed_music_sources = ['kuake', 'netease'] if self.quark_parser_config.get('cookies') else ['netease']
self.default_search_headers = {
"accept": "*/*", "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", "priority": "u=1, i", "x-requested-with": "XMLHttpRequest",
"sec-ch-ua": "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "origin": "https://www.myfreemp3.com.cn",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", "sec-fetch-site": "same-origin",
}
self.default_download_headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
self.default_headers = self.default_search_headers
self._initsession()
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
allowed_music_sources = copy.deepcopy(self.allowed_music_sources)
self.search_size_per_page = min(10, self.search_size_per_page)
# search rules
default_rule = {'type': 'netease', 'filter': 'name', 'page': '1', 'input': keyword}
default_rule.update(rule)
# construct search urls based on search rules
base_url = 'https://www.myfreemp3.com.cn/'
search_urls, page_size = [], self.search_size_per_page
for source in allowed_music_sources:
source_default_rule = copy.deepcopy(default_rule)
source_default_rule['type'], count = source, 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(source_default_rule)
page_rule['page'] = str(int(count // page_size) + 1)
search_urls.append({'url': base_url, 'data': page_rule, 'source': source})
count += page_size
# return
return search_urls
'''_parseneteasesearchresult'''
def _parseneteasesearchresult(self, search_result: dict, request_overrides: dict = None):
request_overrides = request_overrides or {}
if (not isinstance(search_result, dict)) or ('id' not in search_result): return SongInfo(source=self.source)
download_url = self.session.head(f'http://music.163.com/song/media/outer/url?id={search_result["id"]}.mp3', timeout=10, allow_redirects=True, **request_overrides).url
lyric: str = cleanlrc((search_result.get('lrc', '') or '').removeprefix('data:text/plain,'))
duration_in_secs = extractdurationsecondsfromlrc(lyric)
song_info = SongInfo(
raw_data={'search': search_result, 'download': {}, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(search_result.get('author')),
album='NULL', ext=download_url.split('?')[0].split('.')[-1], file_size='NULL', identifier=search_result['id'], duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=lyric,
cover_url=search_result.get('pic'), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), root_source='netease',
)
if not song_info.with_valid_download_url: return SongInfo(source=self.source)
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
return song_info
'''_parsequarksearchresult'''
def _parsequarksearchresult(self, search_result: dict, request_overrides: dict = None):
request_overrides = request_overrides or {}
if (not isinstance(search_result, dict)) or ('url_kk' not in search_result): return SongInfo(source=self.source)
search_result['id'] = urlparse(str(search_result['url_kk'])).path.strip('/').split('/')[-1]
quark_download_url = search_result['url_kk']
download_result, download_url = QuarkParser.parsefromurl(quark_download_url, **self.quark_parser_config)
if not download_url or not str(download_url).startswith('http'): return SongInfo(source=self.source)
duration = [int(float(d)) for d in searchdictbykey(download_result, 'duration') if int(float(d)) > 0]
duration_in_secs = duration[0] if duration else 0
song_name, singers = (lambda s: (m.group(2).strip(), m.group(1).strip()) if (m:=re.search(r'^\s*(.*?)\s*[-–—-]\s*(.*?)(?:\.[A-Za-z0-9]{1,5})?\s*(?:\s*[-–—-]\s*.*)?$', s.strip())) else (re.sub(r'\.[^.]+$', '', s.strip()).strip(), ""))(search_result.get('title'))
lyric: str = cleanlrc((search_result.get('lrc', '') or '').removeprefix('data:text/plain,'))
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(song_name), singers=legalizestring(singers), album='NULL', ext='mp3',
file_size=None, identifier=search_result['id'], duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=lyric, cover_url=search_result.get('pic'), download_url=download_url,
download_url_status=self.quark_audio_link_tester.test(download_url, request_overrides), root_source='quark', default_download_headers=self.quark_default_download_headers,
)
if not song_info.with_valid_download_url: return SongInfo(source=self.source)
song_info.download_url_status['probe_status'] = self.quark_audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']; song_info.ext = song_info.download_url_status['probe_status']['ext']
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
return song_info
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
search_meta = copy.deepcopy(search_url)
search_url, source = search_meta.pop('url'), search_meta.pop('source')
# successful
try:
# --search results
(resp := self.post(search_url, **search_meta, **request_overrides)).raise_for_status()
search_results = resp2json(resp)['data']['list']
for search_result in search_results:
# --download results
try: song_info = {'netease': self._parseneteasesearchresult, 'kuake': self._parsequarksearchresult}[source](search_result, request_overrides)
except Exception: continue
if not song_info.with_valid_download_url: continue
# --append to song_infos
song_infos.append(song_info)
# --judgement for search_size
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
# --update progress
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
# failure
except Exception as err:
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
# return
return song_infos
@@ -0,0 +1,145 @@
'''
Function:
Implementation of TuneHubMusicClient: https://tunehub.sayqz.com/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
from __future__ import annotations
import copy
import random
import base64
import requests
from rich.progress import Progress
from ..sources import BaseMusicClient
from urllib.parse import urlparse, parse_qs
from ..utils import legalizestring, resp2json, usesearchheaderscookies, seconds2hms, extractdurationsecondsfromlrc, safeextractfromdict, cleanlrc, SongInfo, AudioLinkTester
'''TuneHubMusicClient'''
class TuneHubMusicClient(BaseMusicClient):
source = 'TuneHubMusicClient'
ALLOWED_SITES = ['netease', 'qq', 'kuwo', 'kugou', 'migu'][:3] # it seems kugou and migu are useless, recorded in 2026-01-28
MUSIC_QUALITIES = ['flac24bit', 'flac', '320k', '128k']
BAKA_MUSIC_QUALITIES = ['400', '380', '320', '128']
REQUEST_API_KEYS = ['dGhfOGYwMGQ4NzA5ZGJhOWQ0NDgwYmExOTE2NjgxNDdlMWI3YjkzNjkyMDkyMGZhNjZm', 'dGhfZDgzYzY4YjA5NDVlYzYxMjZjNDQxMzkwN2MxYzc3MmI3YmI3ZGUwODU4NWI0N2Y1']
def __init__(self, **kwargs):
self.allowed_music_sources = list(set(kwargs.pop('allowed_music_sources', TuneHubMusicClient.ALLOWED_SITES)))
super(TuneHubMusicClient, self).__init__(**kwargs)
decrypt_func = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8')
self.default_search_headers = {
'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',
'X-API-Key': decrypt_func(random.choice(TuneHubMusicClient.REQUEST_API_KEYS)),
}
self.default_download_headers = {
'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',
}
self.default_headers = self.default_search_headers
self._initsession()
'''_tunehubkuwosearch: https://tunehub.sayqz.com/api/v1/methods/kuwo/search'''
def _tunehubkuwosearch(self, keyword: str, page: int = 1, limit: int = 20, timeout: float = 10.0):
url = "http://search.kuwo.cn/r.s"; page = 1 if (page is None or int(page) < 1) else int(page); limit = 20 if (limit is None or int(limit) <= 0) else int(limit)
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
params = {"client": "kt", "all": keyword, "pn": page - 1, "rn": limit, "uid": "794762570", "ver": "kwplayer_ar_9.2.2.1", "vipver": "1", "show_copyright_off": "1", "newver": "1", "ft": "music", "cluster": "0", "strategy": "2012", "encoding": "utf8", "rformat": "json", "vermerge": "1", "mobi": "1", "issubtitle": "1"}
(resp := requests.get(url, params=params, headers=headers, timeout=timeout)).raise_for_status()
data: dict = resp.json(); abslist, out = data.get("abslist"), []
if not abslist: return []
for item in abslist: isinstance(item, dict) and out.append({"id": str(item.get("MUSICRID", "")).replace("MUSIC_", ""), "name": item.get("SONGNAME", ""), "artist": (item.get("ARTIST", "") or "").replace("&", ", "), "album": item.get("ALBUM") or "", "source": "kuwo"})
return out
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
allowed_music_sources = copy.deepcopy(self.allowed_music_sources)
# construct search urls based on search rules
search_urls, page_size = [], self.search_size_per_page
for source in TuneHubMusicClient.ALLOWED_SITES:
if source not in allowed_music_sources: continue
if source in {'netease', 'qq'}:
server = {'netease': 'netease', 'qq': 'tencent'}[source]
search_urls.append(f"https://api.baka.plus/meting?server={server}&type=search&id=0&yrc=false&keyword={keyword}")
else:
source_default_rule, count = {'keyword': keyword, 'page': 1, 'limit': 20, 'timeout': 10.0}, 0
source_default_rule.update(rule)
while self.search_size_per_source > count:
page_rule = copy.deepcopy(source_default_rule)
page_rule['page'] = str(int(count // page_size) + 1)
page_rule['limit'] = str(page_size)
search_urls.append({'search_api': {'kuwo': self._tunehubkuwosearch}[source], 'rule': page_rule})
count += page_size
# return
return search_urls
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: str | dict = None, request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
# successful
try:
# --search results
if isinstance(search_url, dict): search_results = search_url['search_api'](**search_url['rule'])
else: (resp := self.get(search_url, **request_overrides)).raise_for_status(); search_results = resp2json(resp)
for search_result in search_results:
# --download results
if not isinstance(search_result, dict) or ('id' not in search_result and 'url' not in search_result) or ('source' not in search_result): continue
if 'id' not in search_result: search_result['id'] = parse_qs(urlparse(str(search_result['url'])).query, keep_blank_values=True).get('id')[0]
search_result['source'] = {'netease': 'netease', 'tencent': 'qq', 'kuwo': 'kuwo'}[search_result['source']]
song_info = SongInfo(source=self.source, root_source=search_result['source'])
if search_result['source'] in {'netease', 'qq'}:
for br in (TuneHubMusicClient.BAKA_MUSIC_QUALITIES if search_result['source'] in {'netease'} else TuneHubMusicClient.BAKA_MUSIC_QUALITIES[:1]):
params = {'br': br, 'id': search_result['id'], 'server': {'netease': 'netease', 'qq': 'tencent', 'kuwo': 'kuwo'}[search_result['source']], 'type': 'url'}
try: (resp := self.session.head('https://api.baka.plus/meting?', timeout=10, params=params, allow_redirects=True, **request_overrides)).raise_for_status(); download_url = resp.url
except Exception: continue
try: (resp := self.session.head(safeextractfromdict(search_result, ['pic'], None), timeout=10, allow_redirects=True, **request_overrides)).raise_for_status(); cover_url = resp.url
except Exception: cover_url = safeextractfromdict(search_result, ['pic'], None) or ""
song_info = SongInfo(
raw_data={'search': search_result, 'download': {}, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(search_result.get('artist')),
album=legalizestring(search_result.get('album', None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=search_result['id'], duration='-:-:-',
lyric=None, cover_url=cover_url, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), root_source=search_result['source'],
)
if song_info.root_source in ['tencent']: song_info.root_source = 'qq'
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
if song_info.with_valid_download_url: break
elif search_result['source'] in {'kuwo'}:
for quality in TuneHubMusicClient.MUSIC_QUALITIES:
data = {'quality': quality, 'ids': search_result['id'], 'platform': search_result['source']}
try: (resp := self.post('https://tunehub.sayqz.com/api/v1/parse?', timeout=10, data=data, **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
except Exception: break
download_url = safeextractfromdict(download_result, ['data', 'data', 0, 'url'], "")
if not download_url or not download_url.startswith('http'): continue
duration_in_secs = safeextractfromdict(download_result, ['data', 'data', 0, 'info', 'duration'], 0)
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(search_result.get('artist')),
album=legalizestring(search_result.get('album', None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=search_result['id'], duration_s=duration_in_secs,
duration=seconds2hms(duration_in_secs), lyric=safeextractfromdict(download_result, ['data', 'data', 0, 'lyrics'], None), cover_url=safeextractfromdict(download_result, ['data', 'data', 0, 'cover'], None),
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), root_source=search_result['source'],
)
if str(song_info.lyric).startswith('http'): search_result['lrc'] = song_info.lyric; song_info.lyric = None
if song_info.lyric: song_info.lyric = cleanlrc(song_info.lyric)
song_info.download_url_status['probe_status'] = self.audio_link_tester.probe(song_info.download_url, request_overrides)
song_info.file_size = song_info.download_url_status['probe_status']['file_size']
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (song_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = song_info.download_url_status['probe_status']['ext']
elif (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
if song_info.with_valid_download_url: break
if not song_info.with_valid_download_url: continue
# --lyric results
try: (resp := self.get(search_result['lrc'], **request_overrides)).raise_for_status(); lyric, lyric_result = cleanlrc(resp.text), {'lyric': resp.text}; song_info.duration_s = extractdurationsecondsfromlrc(lyric); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: lyric_result, lyric = dict(), 'NULL'
song_info.raw_data['lyric'] = lyric_result if lyric_result else song_info.raw_data['lyric']
song_info.lyric = lyric if (lyric and (lyric not in {'NULL'})) else song_info.lyric
# --append to song_infos
song_infos.append(song_info)
# --judgement for search_size
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
# --update progress
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Success)")
# failure
except Exception as err:
progress.update(progress_id, description=f"{self.source}.search >>> {search_url} (Error: {err})")
# return
return song_infos