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,5 @@
'''initialize'''
from .lrts import LRTSMusicClient
from .lizhi import LizhiMusicClient
from .ximalaya import XimalayaMusicClient
from .qingting import QingtingMusicClient
@@ -0,0 +1,204 @@
'''
Function:
Implementation of LizhiMusicClient: https://www.lizhi.fm/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import re
import copy
from contextlib import suppress
from urllib.parse import urlencode
from rich.progress import Progress
from ..sources import BaseMusicClient
from ..utils import legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, SongInfo
'''LizhiMusicClient'''
class LizhiMusicClient(BaseMusicClient):
source = 'LizhiMusicClient'
ALLOWED_SEARCH_TYPES = ['album', 'track']
MUSIC_QUALITIES = ['_ud.mp3', '_hd.mp3', '_sd.m4a']
def __init__(self, **kwargs):
self.allowed_search_types = list(set(kwargs.pop('allowed_search_types', LizhiMusicClient.ALLOWED_SEARCH_TYPES)))
super(LizhiMusicClient, self).__init__(**kwargs)
self.default_search_headers = {'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', 'Referer': 'https://m.lizhi.fm'}
self.default_download_headers = {'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'}
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, 20)
# construct search urls based on search rules
search_urls, page_size = [], self.search_size_per_page
for search_type in LizhiMusicClient.ALLOWED_SEARCH_TYPES:
if search_type not in self.allowed_search_types: continue
if search_type in {'track'}:
default_rule = {'deviceId': "h5-b6ef91a9-3dbb-c716-1fdd-43ba08851150", "keywords": keyword, "page": 1, "receiptData": ""}
default_rule.update(rule)
base_url, count = 'https://m.lizhi.fm/vodapi/search/voice?', 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(default_rule)
page_rule['page'] = str(int(count // page_size) + 1)
if count > 0:
with suppress(Exception): receipt_data = resp2json(self.get(search_urls[-1]['url'], **request_overrides)).get('receiptData', '')
page_rule['receiptData'] = receipt_data
search_urls.append({'url': base_url + urlencode(page_rule), 'type': search_type})
count += page_size
elif search_type in ['album']:
default_rule = {'deviceId': "h5-b6ef91a9-3dbb-c716-1fdd-43ba08851150", "keywords": keyword, "page": 1, "receiptData": ""}
default_rule.update(rule)
base_url, count = 'https://m.lizhi.fm/vodapi/search/voice?', 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(default_rule)
page_rule['page'] = str(int(count // page_size) + 1)
if count > 0:
with suppress(Exception): receipt_data = resp2json(self.get(search_urls[-1]['url'], **request_overrides)).get('receiptData', '')
page_rule['receiptData'] = receipt_data
search_urls.append({'url': base_url + urlencode(page_rule), 'type': search_type})
count += page_size
# return
return search_urls
'''_parsewithofficialapiv1'''
def _parsewithofficialapiv1(self, search_result: dict, request_overrides: dict = None):
# init
request_overrides, song_id, song_info = request_overrides or {}, safeextractfromdict(search_result, ['voiceInfo', 'voiceId'], ''), SongInfo(source=self.source)
# parse
(resp := self.get(f'https://m.lizhi.fm/vodapi/voice/info/{song_id}', **request_overrides)).raise_for_status()
download_result = resp2json(resp=resp)
download_url = safeextractfromdict(download_result, ['data', 'userVoice', 'voicePlayProperty', 'trackUrl'], '')
if not download_url or not str(download_url).startswith('http'):
image_url = safeextractfromdict(download_result, ['data', 'userVoice', 'voiceInfo', 'imageUrl'], "") or ""
m = re.search(r'/(\d{4}/\d{2}/\d{2})(?:/|$)', str(image_url))
if not m: return song_info
download_url = f'https://cdn101.lizhi.fm/audio/{m.group(1)}/{song_id}_sd.m4a' # cdn101 is better than cdn5
for quality in LizhiMusicClient.MUSIC_QUALITIES:
download_url: str = (download_url[:-7] + quality).replace('//cdn5.lizhi.fm/audio/', '//cdn101.lizhi.fm/audio/')
duration_in_secs = safeextractfromdict(download_result, ['data', 'userVoice', 'voiceInfo', 'duration'], 0) or 0
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'userVoice', 'voiceInfo', 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['data', 'userVoice', 'userInfo', 'name'], None)), album=legalizestring(safeextractfromdict(download_result, ['data', 'userVoice', 'userInfo', 'name'], None)),
ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=safeextractfromdict(download_result, ['data', 'userVoice', 'voiceInfo', 'imageUrl'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
if not song_info.with_valid_download_url: song_info.update(dict(download_url=download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), download_url_status=self.audio_link_tester.test(download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), request_overrides)))
if song_info.with_valid_download_url: break
if not song_info.with_valid_download_url: return song_info
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']
# return
return song_info
'''_parsebytrack'''
def _parsebytrack(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results:
if not isinstance(search_result, dict) or not safeextractfromdict(search_result, ['voiceInfo', 'voiceId'], ''): continue
song_info, song_id = SongInfo(source=self.source), safeextractfromdict(search_result, ['voiceInfo', 'voiceId'], '')
download_url = safeextractfromdict(search_result, ['voicePlayProperty', 'trackUrl'], '')
if not download_url or not str(download_url).startswith('http'):
image_url = safeextractfromdict(search_result, ['voiceInfo', 'imageUrl'], "") or ""
m = re.search(r'/(\d{4}/\d{2}/\d{2})(?:/|$)', str(image_url))
if not m: continue
download_url = f'https://cdn101.lizhi.fm/audio/{m.group(1)}/{song_id}_sd.m4a' # cdn101 is better than cdn5
for quality in LizhiMusicClient.MUSIC_QUALITIES:
download_url: str = (download_url[:-7] + quality).replace('//cdn5.lizhi.fm/audio/', '//cdn101.lizhi.fm/audio/')
duration_in_secs = safeextractfromdict(search_result, ['voiceInfo', 'duration'], 0)
song_info = SongInfo(
raw_data={'search': search_result, 'download': {}, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(search_result, ['voiceInfo', 'name'], None)), singers=legalizestring(safeextractfromdict(search_result, ['userInfo', 'name'], None)),
album=legalizestring(safeextractfromdict(search_result, ['userInfo', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration_s=duration_in_secs or 0, duration=seconds2hms(duration_in_secs),
lyric=None, cover_url=safeextractfromdict(search_result, ['voiceInfo', 'imageUrl'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
if not song_info.with_valid_download_url: song_info.update(dict(download_url=download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), download_url_status=self.audio_link_tester.test(download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), request_overrides)))
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']
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_parsebyalbum'''
def _parsebyalbum(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides, unique_album_ids = request_overrides or {}, set()
for search_result in search_results:
if not isinstance(search_result, dict) or not safeextractfromdict(search_result, ['userInfo', 'userId'], ''): continue
album_id = safeextractfromdict(search_result, ['userInfo', 'userId'], '')
if album_id in unique_album_ids: continue
unique_album_ids.add(album_id)
download_results, page_size, page_no, track_idx, unique_track_ids = [], 1000, 1, 0, set()
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_results, 'lyric': {}}, source=self.source, song_name=album_id, singers=legalizestring(safeextractfromdict(search_result, ['userInfo', 'name'], '')),
album=f"{safeextractfromdict(search_result, ['userInfo', 'audioNum'], 0) or 0} Episodes", ext=None, file_size_bytes=None, file_size=None, identifier=album_id, duration_s=None, duration='-:-:-', lyric=None,
cover_url=safeextractfromdict(search_result, ['userInfo', 'photo'], None), download_url=None, download_url_status={}, episodes=[],
)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/0) pages downloaded in album {album_id}", total=0)
while True:
try: (resp := self.get(f'https://m.lizhi.fm/vodapi/user/{album_id}?pageNo={page_no}&pageSize={page_size}', **request_overrides)).raise_for_status()
except Exception: break
download_result = resp2json(resp=resp)
if not download_result.get('data'): break
download_results.append(download_result)
page_no += 1
progress.update(download_album_pid, total=page_no, completed=page_no)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({page_no}/{page_no}) pages downloaded in album {album_id}")
total_episodes = sum([len(safeextractfromdict(download_result, ['data'], []) or []) for download_result in download_results])
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{total_episodes}) episodes completed in album {album_id}", total=total_episodes)
for download_result in download_results:
for track in (safeextractfromdict(download_result, ['data'], []) or []):
track_idx += 1
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx}/{total_episodes}) episodes completed in album {album_id}")
if not isinstance(track, dict) or not safeextractfromdict(track, ['voiceInfo', 'voiceId'], ''): continue
eps_info, eps_id = SongInfo(source=self.source), safeextractfromdict(track, ['voiceInfo', 'voiceId'], '')
if eps_id in unique_track_ids: continue
unique_track_ids.add(eps_id)
download_url = safeextractfromdict(track, ['voicePlayProperty', 'trackUrl'], '')
if not download_url or not str(download_url).startswith('http'):
image_url = safeextractfromdict(track, ['voiceInfo', 'imageUrl'], "") or ""
m = re.search(r'/(\d{4}/\d{2}/\d{2})(?:/|$)', str(image_url))
if not m: continue
download_url = f'https://cdn101.lizhi.fm/audio/{m.group(1)}/{eps_id}_sd.m4a' # cdn101 is better than cdn5
for quality in LizhiMusicClient.MUSIC_QUALITIES:
download_url: str = (download_url[:-7] + quality).replace('//cdn5.lizhi.fm/audio/', '//cdn101.lizhi.fm/audio/')
duration_in_secs = safeextractfromdict(track, ['voiceInfo', 'duration'], 0) or 0
eps_info = SongInfo(
raw_data={'search': search_result, 'download': track, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(track, ['voiceInfo', 'name'], None)), singers=legalizestring(safeextractfromdict(track, ['userInfo', 'name'], None)),
album=legalizestring(safeextractfromdict(track, ['userInfo', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=eps_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs),
lyric=None, cover_url=safeextractfromdict(track, ['voiceInfo', 'imageUrl'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
if not eps_info.with_valid_download_url: eps_info.update(dict(download_url=download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), download_url_status=self.audio_link_tester.test(download_url.replace('//cdn101.lizhi.fm/audio/', '//cdn5.lizhi.fm/audio/'), request_overrides)))
if eps_info.with_valid_download_url: break
if not eps_info.with_valid_download_url: continue
eps_info.download_url_status['probe_status'] = self.audio_link_tester.probe(eps_info.download_url, request_overrides)
eps_info.file_size = eps_info.download_url_status['probe_status']['file_size']
song_info.episodes.append(eps_info)
if not song_info.with_valid_download_url: continue
try: song_info.duration_s = sum([eps.duration_s for eps in song_info.episodes]); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: pass
try: song_info.file_size = str(round(sum([float(eps.file_size.removesuffix('MB').strip()) for eps in song_info.episodes]), 2)) + ' MB'
except Exception: pass
song_info.album = f"{len(song_info.episodes)} Episodes"
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
search_type, search_url = search_url['type'], search_url['url']
# successful
try:
# --search results
(resp := self.get(search_url, **request_overrides)).raise_for_status()
search_results = resp2json(resp)['data']
# --parse based on search type
parsers = {'album': self._parsebyalbum, 'track': self._parsebytrack}
parsers[search_type](search_results, song_infos=song_infos, request_overrides=request_overrides, progress=progress)
# --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,208 @@
'''
Function:
Implementation of LRTSMusicClient: https://www.lrts.me/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import copy
import math
from rich.progress import Progress
from urllib.parse import urlencode
from ..sources import BaseMusicClient
from ..utils import legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, byte2mb, SongInfo
'''LRTSMusicClient'''
class LRTSMusicClient(BaseMusicClient):
source = 'LRTSMusicClient'
ALLOWED_SEARCH_TYPES = ['album', 'book']
def __init__(self, **kwargs):
self.allowed_search_types = list(set(kwargs.pop('allowed_search_types', LRTSMusicClient.ALLOWED_SEARCH_TYPES)))
super(LRTSMusicClient, self).__init__(**kwargs)
self.default_search_headers = {
"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", "accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "cache-control": "max-age=0", "connection": "keep-alive", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
"host": "m.lrts.me", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "sec-fetch-user": "?1",
"upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.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/145.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 {}
# search rules
default_rule = {"keyWord": keyword, "pageSize": "40", "pageNum": "1", "searchOption": "1"}
default_rule.update(rule)
# construct search urls based on search rules
base_url = 'https://m.lrts.me/ajax/search?'
search_urls, page_size = [], max(self.search_size_per_page, 40)
for search_type in LRTSMusicClient.ALLOWED_SEARCH_TYPES:
if search_type not in self.allowed_search_types: continue
default_rule_search_type, count = copy.deepcopy(default_rule), 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(default_rule_search_type)
page_rule['pageSize'] = str(page_size)
page_rule['pageNum'] = str(int(count // page_size) + 1)
search_urls.append({search_type: base_url + urlencode(page_rule)})
count += page_size
# return
return search_urls
'''_parsebookwithofficialapiv1'''
def _parsebookwithofficialapiv1(self, section_idx, search_result: dict, request_overrides: dict = None):
# init
request_overrides, book_id, song_id, song_info = request_overrides or {}, safeextractfromdict(search_result, ['book_info', 'id'], ''), search_result.get('id') or search_result.get('sectionId'), SongInfo(source=self.source)
# parse
try: (resp := self.get(f"https://m.lrts.me/ajax/getPlayPath?entityId={book_id}&entityType=3&opType=1&sections=[{section_idx}]&type=0&id={song_id}&section={section_idx}", **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
except Exception: download_result = {}
download_url = safeextractfromdict(download_result, ['list', 0, 'path'], '')
if not download_url or not download_url.startswith('http'):
try: (resp := self.get(f"https://m.lrts.me/ajax/getListenPath?entityId={book_id}&entityType=3&opType=1&sections=[{section_idx}]&type=0&id={song_id}&section={section_idx}", **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
except Exception: download_result = {}
download_url = safeextractfromdict(download_result, ['data', 'path'], '')
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(safeextractfromdict(search_result, ['book_info', 'announcer'], None)),
album=legalizestring(safeextractfromdict(search_result, ['book_info', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=float(search_result.get('size', 0) or 0), file_size=byte2mb(search_result.get('size', 0) or 0),
identifier=song_id, duration_s=int(float(search_result.get('length', 0.0) or 0.0)), duration=seconds2hms(int(float(search_result.get('length', 0.0) or 0.0))), lyric=None, cover_url=safeextractfromdict(search_result, ['book_info', 'cover'], None),
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
# return
return song_info
'''_parsealbumwithofficialapiv1'''
def _parsealbumwithofficialapiv1(self, section_idx, search_result: dict, request_overrides: dict = None):
# init
request_overrides, album_id, song_id, song_info = request_overrides or {}, safeextractfromdict(search_result, ['album_info', 'id'], ''), search_result.get('audioId') or search_result.get('sectionId'), SongInfo(source=self.source)
# parse
try: (resp := self.get(f"https://m.lrts.me/ajax/getPlayPath?entityId={album_id}&entityType=2&opType=1&sections=[{song_id}]&type=0", **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
except Exception: download_result = {}
download_url = safeextractfromdict(download_result, ['list', 0, 'path'], '')
if not download_url or not download_url.startswith('http'):
try: (resp := self.get(f"https://m.lrts.me/ajax/getListenPath?entityId={album_id}&entityType=2&opType=1&sections=[{section_idx}]&type=0&id={song_id}&section={section_idx}", **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
except Exception: download_result = {}
download_url = safeextractfromdict(download_result, ['data', 'path'], '')
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(safeextractfromdict(search_result, ['album_info', 'nickName'], None)),
album=legalizestring(safeextractfromdict(search_result, ['album_info', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=float(search_result.get('size', 0) or 0), file_size=byte2mb(search_result.get('size', 0) or 0),
identifier=song_id, duration_s=int(float(search_result.get('length', 0.0) or 0.0)), duration=seconds2hms(int(float(search_result.get('length', 0.0) or 0.0))), lyric=None, cover_url=safeextractfromdict(search_result, ['album_info', 'cover'], None),
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
# return
return song_info
'''_parsebybook'''
def _parsebybook(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['data']['bookResult']['list']:
if (not isinstance(search_result, dict)) or ('id' not in search_result): continue
download_results, tracks, page_size, unique_track_ids = [], [], 50, set()
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_results, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name', None)),
singers=legalizestring(search_result.get('announcer')), album=f"{safeextractfromdict(search_result, ['sections'], 0) or 0} Episodes", ext=None, file_size=None,
identifier=search_result['id'], duration='-:-:-', lyric=None, cover_url=search_result.get('cover', None), download_url=None, download_url_status={}, episodes=[],
)
num_pages = math.ceil(int(safeextractfromdict(search_result, ['sections'], 0) or 0) / page_size)
download_book_pid = progress.add_task(f"{self.source}._parsebybook >>> (0/{num_pages}) pages downloaded in book {search_result['id']}", total=num_pages)
for page_num_idx, page_num in enumerate(range(1, num_pages + 1)):
if page_num_idx > 0:
progress.advance(download_book_pid, 1)
progress.update(download_book_pid, description=f"{self.source}._parsebybook >>> ({page_num_idx}/{num_pages}) pages downloaded in book {search_result['id']}")
try: download_results.append(resp2json(self.get(f'https://m.lrts.me/ajax/getBookMenu?bookId={search_result["id"]}&pageNum={page_num}&pageSize={page_size}&sortType=0', **request_overrides)))
except: continue
progress.advance(download_book_pid, 1)
progress.update(download_book_pid, description=f"{self.source}._parsebybook >>> ({page_num_idx+1}/{num_pages}) pages downloaded in book {search_result['id']}")
for download_result in download_results:
for track in (safeextractfromdict(download_result, ['list'], []) or []):
if not isinstance(track, dict) or not track.get('id'): continue
if track.get('id') in unique_track_ids: continue
unique_track_ids.add(track.get('id'))
tracks.append(track)
download_book_pid = progress.add_task(f"{self.source}._parsebybook >>> (0/{len(tracks)}) episodes completed in book {search_result['id']}", total=len(tracks))
for track_idx, track in enumerate(tracks):
if track_idx > 0:
progress.advance(download_book_pid, 1)
progress.update(download_book_pid, description=f"{self.source}._parsebybook >>> ({track_idx}/{len(tracks)}) episodes completed in book {search_result['id']}")
eps_info, track['book_info'] = SongInfo(source=self.source), copy.deepcopy(search_result)
for parser in [self._parsebookwithofficialapiv1]:
try: eps_info = parser(section_idx=track_idx+1, search_result=track, request_overrides=request_overrides)
except: continue
if eps_info.with_valid_download_url: break
if not eps_info.with_valid_download_url: continue
song_info.episodes.append(eps_info)
progress.advance(download_book_pid, 1)
progress.update(download_book_pid, description=f"{self.source}._parsebybook >>> ({track_idx+1}/{len(tracks)}) episodes completed in book {search_result['id']}")
if not song_info.with_valid_download_url: continue
try: song_info.duration_s = sum([eps.duration_s for eps in song_info.episodes]); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: pass
try: song_info.file_size_bytes = sum([eps.file_size_bytes for eps in song_info.episodes]); song_info.file_size = byte2mb(song_info.file_size_bytes)
except Exception: pass
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_parsebyalbum'''
def _parsebyalbum(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['data']['albumResult']['list']:
if (not isinstance(search_result, dict)) or ('id' not in search_result): continue
download_results, tracks, unique_track_ids = [], [], set()
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_results, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name', None)),
singers=legalizestring(search_result.get('nickName')), album=f"{safeextractfromdict(search_result, ['sections'], 0) or 0} Episodes", ext=None, file_size=None,
identifier=search_result['id'], duration='-:-:-', lyric=None, cover_url=search_result.get('cover', None), download_url=None, download_url_status={}, episodes=[],
)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/1) pages downloaded in album {search_result['id']}", total=1)
try: (resp := self.get(f'https://m.lrts.me/ajax/getAlbumAudios?ablumnId={search_result["id"]}&sortType=0')).raise_for_status()
except Exception: continue
download_results.append(resp2json(resp=resp))
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> (1/1) pages downloaded in album {search_result['id']}")
for download_result in download_results:
for track in (safeextractfromdict(download_result, ['list'], []) or []):
if not isinstance(track, dict) or not track.get('audioId'): continue
if track.get('audioId') in unique_track_ids: continue
unique_track_ids.add(track.get('audioId'))
tracks.append(track)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{len(tracks)}) episodes completed in album {search_result['id']}", total=len(tracks))
for track_idx, track in enumerate(tracks):
if track_idx > 0:
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx}/{len(tracks)}) episodes completed in album {search_result['id']}")
eps_info, track['album_info'] = SongInfo(source=self.source), copy.deepcopy(search_result)
for parser in [self._parsealbumwithofficialapiv1]:
try: eps_info = parser(section_idx=track_idx+1, search_result=track, request_overrides=request_overrides)
except: continue
if eps_info.with_valid_download_url: break
if not eps_info.with_valid_download_url: continue
song_info.episodes.append(eps_info)
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx+1}/{len(tracks)}) episodes completed in album {search_result['id']}")
if not song_info.with_valid_download_url: continue
try: song_info.duration_s = sum([eps.duration_s for eps in song_info.episodes]); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: pass
try: song_info.file_size_bytes = sum([eps.file_size_bytes for eps in song_info.episodes]); song_info.file_size = byte2mb(song_info.file_size_bytes)
except Exception: pass
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: dict = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
(search_type, search_url), = search_url.items()
# successful
try:
# --search results
(resp := self.get(search_url, **request_overrides)).raise_for_status()
search_results = resp2json(resp)
# --parse based on search type
parsers = {'album': self._parsebyalbum, 'book': self._parsebybook}
parsers[search_type](search_results, song_infos=song_infos, request_overrides=request_overrides, progress=progress)
# --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,202 @@
'''
Function:
Implementation of QingtingMusicClient: https://m.qingting.fm/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
from __future__ import annotations
import copy
import hmac
import math
import hashlib
from rich.progress import Progress
from typing import Any, Dict, List
from ..sources import BaseMusicClient
from urllib.parse import urlencode, urlparse, parse_qs
from ..utils import legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, byte2mb, SongInfo
'''QingtingMusicClient'''
class QingtingMusicClient(BaseMusicClient):
source = 'QingtingMusicClient'
HMAC_KEY = "99@b8#571(bb38_b"
DEVICE_ID = "66f6e3b560ad8876e52e6e67ee535c5c"
ALLOWED_SEARCH_TYPES = ['album', 'track']
def __init__(self, **kwargs):
self.allowed_search_types = list(set(kwargs.pop('allowed_search_types', QingtingMusicClient.ALLOWED_SEARCH_TYPES)))
super(QingtingMusicClient, self).__init__(**kwargs)
if self.default_search_cookies: assert ("qingting_id" in self.default_search_cookies) and (("access_token" in self.default_search_cookies) or ("refresh_token" in self.default_search_cookies)), '"qingting_id", "access_token" and "refresh_token" should be configured, refer to "https://musicdl.readthedocs.io/en/latest/Quickstart.html#qingtingfm-audio-radio-download"'
if self.default_download_cookies: assert ("qingting_id" in self.default_download_cookies) and (("access_token" in self.default_download_cookies) or ("refresh_token" in self.default_download_cookies)), '"qingting_id", "access_token" and "refresh_token" should be configured, refer to "https://musicdl.readthedocs.io/en/latest/Quickstart.html#qingtingfm-audio-radio-download"'
self.default_search_headers = {"User-Agent": "QingTing-iOS/10.7.9.0 com.Qting.QTTour Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", "QT-App-Version": "10.7.9.0"}
self.default_download_headers = {"User-Agent": "QingTing-iOS/10.7.9.0 com.Qting.QTTour Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", "QT-App-Version": "10.7.9.0"}
self.default_headers = self.default_search_headers
self.auth_info = copy.deepcopy(self.default_search_cookies or self.default_download_cookies)
self.default_search_cookies = {}; self.default_download_cookies = {}
self._initsession()
'''_auth'''
def _auth(self, request_overrides: dict = None):
request_overrides = request_overrides or {}
qingting_id, refresh_token = self.auth_info['qingting_id'], self.auth_info['refresh_token']
(resp := self.post("https://user.qtfm.cn/u2/api/v4/auth", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={"refresh_token": refresh_token, "qingting_id": qingting_id, "device_id": QingtingMusicClient.DEVICE_ID, "grant_type": "refresh_token"}, **request_overrides)).raise_for_status()
auth_info = resp2json(resp)['data']
self.auth_info = copy.deepcopy(auth_info)
return auth_info
'''_constructsearchurls'''
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
# init
rule, request_overrides = rule or {}, request_overrides or {}
if self.auth_info and ("access_token" not in self.auth_info): self._auth()
# search rules: sort_type should be in {"0", "1", "2"} >>> {Comprehensive Sorting, Most Popular, Latest Updates}; include should be in {"channel_ondemand", "channel_live", "program_ondemand", "people_podcaster", "all"}
default_rule = {"k": keyword, "sort_type": '0', "page": "1", "include": "channel_ondemand", "pagesize": "30", "k_src": "direct"}
default_rule.update(rule)
# construct search urls based on search rules
base_url = 'https://app.qtfm.cn/m-bff/v1/search/result?'
search_urls, page_size = [], self.search_size_per_page
for search_type in QingtingMusicClient.ALLOWED_SEARCH_TYPES:
if search_type not in self.allowed_search_types: continue
default_rule_search_type = copy.deepcopy(default_rule)
default_rule_search_type['include'], count = {"album": "channel_ondemand", "track": "program_ondemand"}[search_type], 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(default_rule_search_type)
page_rule['pagesize'] = str(page_size)
page_rule['page'] = str(int(count // page_size) + 1)
search_urls.append(base_url + urlencode(page_rule))
count += page_size
# return
return search_urls
'''_fetchchannelinfo'''
def _fetchchannelinfo(self, channel_id: str, request_overrides: dict = None) -> Dict[str, Any]:
request_overrides = request_overrides or {}
url = f"https://app.qtfm.cn/m-bff/v2/channel/{channel_id}"
(resp := self.get(url, **request_overrides)).raise_for_status()
channel_info = resp2json(resp=resp)
return channel_info
'''_listpageprograms'''
def _listpageprograms(self, channel_id: str, page: int, page_size: int, request_overrides: dict = None) -> List[Dict[str, Any]]:
request_overrides = request_overrides or {}
url = f"https://app.qtfm.cn/m-bff/v2/channel/{channel_id}/programs"
(resp := self.get(url, params={"order": "asc", "pagesize": str(page_size), "curpage": str(page)}, **request_overrides)).raise_for_status()
programs = resp2json(resp=resp)
return programs
'''_parsewithofficialapiv1'''
def _parsewithofficialapiv1(self, search_result: dict, request_overrides: dict = None):
# init
request_overrides, song_id, song_info, app_url = request_overrides or {}, search_result.get('id') or search_result.get('Id'), SongInfo(source=self.source), search_result.get('url')
if not song_id or not app_url: return song_info
hmac_md5_hex_func = lambda key, msg: hmac.new(str(key).encode("utf-8"), str(msg).encode("utf-8"), hashlib.md5).hexdigest()
# parse
parsed_app_url_params = parse_qs(urlparse(str(app_url)).query, keep_blank_values=True)
channel_id, program_id = parsed_app_url_params.get('channel_id')[0], (parsed_app_url_params.get('program_id') or [song_id])[0]
assert str(song_id) == str(program_id), 'song_id and app_url are not synchronized'
path_query = f"/m-bff/v1/audiostreams/channel/{channel_id}/program/{program_id}?access_token={self.auth_info.get('access_token', '')}&device_id={QingtingMusicClient.DEVICE_ID}&qingting_id={self.auth_info.get('qingting_id', '')}&type=play"
sign = hmac_md5_hex_func(QingtingMusicClient.HMAC_KEY, path_query)
(resp := self.get(f"https://app.qtfm.cn{path_query}&sign={sign}", **request_overrides)).raise_for_status()
download_result = resp2json(resp=resp)
if 'channel_info' not in search_result:
try: search_result['channel_info'] = self._fetchchannelinfo(channel_id, request_overrides)
except Exception: pass
candidate_editions: list[dict] = sorted(download_result['data']['editions'] + (download_result['data'].get('backup_editions') if isinstance(download_result['data'].get('backup_editions'), list) else []), key=lambda x: (x.get('size', 0), x.get('bitrate', 0)), reverse=True)
for edition in candidate_editions:
if not edition.get('urls'): continue
if isinstance(edition.get('urls'), str): edition['urls'] = [edition.get('urls')]
for download_url in edition.get('urls'):
if not download_url or not str(download_url).startswith('http'): continue
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(', '.join([singer.get('nick_name') for singer in (safeextractfromdict(search_result, ['channel_info', 'data', 'podcasters'], []) or []) if isinstance(singer, dict) and singer.get('nick_name')])),
album=legalizestring(safeextractfromdict(search_result, ['channel_info', 'data', 'title'], None) or search_result.get('desc')), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=int(float(edition.get('size', 0) or 0)) * 1024, file_size=byte2mb(int(float(edition.get('size', 0) or 0)) * 1024), identifier=song_id, duration_s=search_result.get('duration', 0),
duration=seconds2hms(search_result.get('duration', 0) or 0), lyric=None, cover_url=safeextractfromdict(search_result, ['cover'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
if song_info.with_valid_download_url: break
if song_info.with_valid_download_url: break
# return
return song_info
'''_parsebytrack'''
def _parsebytrack(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['data']['data']:
if (not isinstance(search_result, dict)) or ('id' not in search_result) or (search_result.get('type') not in {'program'}): continue
song_info = SongInfo(source=self.source)
for parser in [self._parsewithofficialapiv1]:
try: song_info = parser(search_result=search_result, request_overrides=request_overrides)
except: continue
if song_info.with_valid_download_url: break
if not song_info.with_valid_download_url: continue
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_parsebyalbum'''
def _parsebyalbum(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['data']['data']:
if (not isinstance(search_result, dict)) or ('id' not in search_result) or (search_result.get('type') not in {'channel_ondemand'}): continue
try: search_result['channel_info'] = self._fetchchannelinfo(search_result['id'], request_overrides)
except Exception: pass
download_results, page_size, tracks, unique_track_ids = [], 100, [], set()
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_results, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['podcaster', 'name'], None)),
album=f"{safeextractfromdict(search_result, ['channel_info', 'data', 'program_count'], 0) or 0} Episodes", ext=None, file_size=None, identifier=search_result['id'], duration='-:-:-', lyric=None, cover_url=search_result.get('cover', None),
download_url=None, download_url_status={}, episodes=[],
)
num_pages = math.ceil(int(safeextractfromdict(search_result, ['channel_info', 'data', 'program_count'], 0) or 0) / page_size)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{num_pages}) pages downloaded in album {search_result['id']}", total=num_pages)
for page_num_idx, page_num in enumerate(range(1, num_pages + 1)):
if page_num_idx > 0:
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({page_num_idx}/{num_pages}) pages downloaded in album {search_result['id']}")
try: download_results.append(self._listpageprograms(search_result['id'], page=page_num, page_size=page_size, request_overrides=request_overrides))
except: continue
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({page_num_idx+1}/{num_pages}) pages downloaded in album {search_result['id']}")
for download_result in download_results:
for track in (safeextractfromdict(download_result, ['data', 'programs'], []) or []):
if not isinstance(track, dict) or not track.get('id'): continue
if track.get('id') in unique_track_ids: continue
unique_track_ids.add(track.get('id'))
tracks.append(track)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{len(tracks)}) episodes completed in album {search_result['id']}", total=len(tracks))
for track_idx, track in enumerate(tracks):
if track_idx > 0:
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx}/{len(tracks)}) episodes completed in album {search_result['id']}")
eps_info, track['channel_info'] = SongInfo(source=self.source), search_result.get('channel_info', {})
track['url'] = f"qingtingfm://app.qingting.fm/playingview?type=ondemand&channel_id={search_result['id']}&program_id={track['id']}"
for parser in [self._parsewithofficialapiv1]:
try: eps_info = parser(search_result=track, request_overrides=request_overrides)
except: continue
if eps_info.with_valid_download_url: break
if not eps_info.with_valid_download_url: continue
song_info.episodes.append(eps_info)
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx+1}/{len(tracks)}) episodes completed in album {search_result['id']}")
if not song_info.with_valid_download_url: continue
try: song_info.duration_s = sum([eps.duration_s for eps in song_info.episodes]); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: pass
try: song_info.file_size_bytes = sum([eps.file_size_bytes for eps in song_info.episodes]); song_info.file_size = byte2mb(song_info.file_size_bytes)
except Exception: pass
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
# successful
try:
# --search results
(resp := self.get(search_url, **request_overrides)).raise_for_status()
search_results = resp2json(resp)
# --parse based on search type
search_type = parse_qs(urlparse(search_url).query, keep_blank_values=True).get('include')[0]
parsers = {'channel_ondemand': self._parsebyalbum, 'program_ondemand': self._parsebytrack}
parsers[search_type](search_results, song_infos=song_infos, request_overrides=request_overrides, progress=progress)
# --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,205 @@
'''
Function:
Implementation of XimalayaMusicClient: https://www.ximalaya.com/
Author:
Zhenchao Jin
WeChat Official Account (微信公众号):
Charles的皮卡丘
'''
import re
import time
import math
import copy
import base64
import binascii
from Crypto.Cipher import AES
from rich.progress import Progress
from ..sources import BaseMusicClient
from urllib.parse import urlencode, urlparse, parse_qs
from ..utils import byte2mb, resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, SongInfo
'''XimalayaMusicClient'''
class XimalayaMusicClient(BaseMusicClient):
source = 'XimalayaMusicClient'
ALLOWED_SEARCH_TYPES = ['album', 'track']
def __init__(self, **kwargs):
self.allowed_search_types = list(set(kwargs.pop('allowed_search_types', XimalayaMusicClient.ALLOWED_SEARCH_TYPES)))
super(XimalayaMusicClient, self).__init__(**kwargs)
self.default_search_headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36",
}
self.default_download_headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile 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 {}
# search rules
default_rule = {
'appid': '0', 'condition': 'relation', 'core': 'track', 'device': 'android', 'deviceId': '9a68144e-de5b-3c60-be5e-adce947ab5ff', 'kw': keyword,
'live': 'true', 'needSemantic': 'true', 'network': 'wifi', 'operator': '1', 'page': '1', 'paidFilter': 'false', 'plan': 'c', 'recall': 'normal',
'rows': self.search_size_per_page, 'search_version': '2.8', 'spellchecker': 'true', 'version': '6.6.48', 'voiceAsinput': 'false',
}
default_rule.update(rule)
# construct search urls based on search rules
base_url = 'https://searchwsa.ximalaya.com/front/v1?'
search_urls, page_size = [], self.search_size_per_page
for search_type in XimalayaMusicClient.ALLOWED_SEARCH_TYPES:
if search_type not in self.allowed_search_types: continue
default_rule_search_type = copy.deepcopy(default_rule)
default_rule_search_type['core'], count = search_type, 0
while self.search_size_per_source > count:
page_rule = copy.deepcopy(default_rule_search_type)
page_rule['rows'] = str(page_size)
page_rule['page'] = str(int(count // page_size) + 1)
search_urls.append(base_url + urlencode(page_rule))
count += page_size
# return
return search_urls
'''_crackplayurl'''
def _crackplayurl(self, ciphertext: str):
if not ciphertext: return ciphertext
key = binascii.unhexlify("aaad3e4fd540b0f79dca95606e72bf93")
ciphertext = base64.urlsafe_b64decode(ciphertext + "=" * (4 - len(ciphertext) % 4))
cipher = AES.new(key, AES.MODE_ECB)
plaintext = cipher.decrypt(ciphertext)
plaintext = re.sub(r"[^\x20-\x7E]", "", plaintext.decode("utf-8"))
return plaintext
'''_parsewithcggapi'''
def _parsewithcggapi(self, search_result: dict, request_overrides: dict = None):
# init
request_overrides, song_id, song_info = request_overrides or {}, search_result.get('id') or search_result.get('trackId'), SongInfo(source=self.source)
# parse
(resp := self.get(f"https://api-v2.cenguigui.cn/api/music/ximalaya.php?trackId={song_id}", **request_overrides)).raise_for_status()
download_result = resp2json(resp=resp)
if ('0 MB' in download_result['size']) or (not download_result.get('url')): return song_info
download_url = download_result['url']
file_size = re.sub(r"^\s*([0-9]*\.?[0-9]+)\s*([A-Za-z]+)\s*$", lambda m: f"{float(m.group(1)):.2f} {m.group(2)}", download_result['size'])
m = re.match(r'^\s*([0-9]*\.?[0-9]+)\s*([KMGT]?B)\s*$', download_result['size'])
file_size_bytes = int(float(m.group(1)) * {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}[m.group(2).upper()])
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(search_result.get('nickname')),
album=legalizestring(search_result.get('album_title') or search_result.get('albumTitle')), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=file_size_bytes, file_size=file_size, identifier=song_id,
duration_s=int(float(search_result.get('duration', 0) or 0)), duration=seconds2hms(search_result.get('duration', 0) or 0), lyric=None, cover_url=safeextractfromdict(search_result, ['cover_path'], None),
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
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']
song_info.ext = song_info.download_url_status['probe_status']['ext'] if (song_info.download_url_status['probe_status']['ext'] and song_info.download_url_status['probe_status']['ext'] not in ('NULL',)) else song_info.ext
return song_info
'''_parsewithofficialapiv1'''
def _parsewithofficialapiv1(self, search_result: dict, request_overrides: dict = None):
# init
request_overrides, song_id, song_info = request_overrides or {}, search_result.get('id') or search_result.get('trackId'), SongInfo(source=self.source)
# parse
params = {"device": "web", "trackId": song_id, "trackQualityLevel": '3'}
(resp := self.get(f"https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/{int(time.time() * 1000)}", params=params, **request_overrides)).raise_for_status()
download_result = resp2json(resp=resp)
track_info = safeextractfromdict(download_result, ['trackInfo'], {})
if not track_info or not isinstance(track_info, dict): return song_info
for encrypted_url in sorted(safeextractfromdict(track_info, ['playUrlList'], []), key=lambda x: int(x['fileSize']), reverse=True):
if not isinstance(encrypted_url, dict): continue
download_url: str = self._crackplayurl(encrypted_url.get('url', ''))
if not download_url or not str(download_url).startswith('http'): continue
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(search_result.get('nickname')),
album=legalizestring(search_result.get('album_title') or search_result.get('albumTitle')), ext=download_url.split('?')[0].split('.')[-1] or 'mp3', file_size_bytes=float(encrypted_url.get('fileSize', 0) or 0),
file_size=byte2mb(encrypted_url.get('fileSize', 0)), identifier=song_id, duration_s=int(float(search_result.get('duration', 0) or 0)), duration=seconds2hms(search_result.get('duration', 0) or 0), lyric=None,
cover_url=safeextractfromdict(search_result, ['cover_path'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
)
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']
song_info.ext = song_info.download_url_status['probe_status']['ext'] if (song_info.download_url_status['probe_status']['ext'] and song_info.download_url_status['probe_status']['ext'] not in ('NULL',)) else song_info.ext
if song_info.with_valid_download_url: break
# return
return song_info
'''_parsebytrack'''
def _parsebytrack(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['response']['docs']:
if (not isinstance(search_result, dict)) or ('id' not in search_result): continue
song_info = SongInfo(source=self.source)
for parser in [self._parsewithcggapi, self._parsewithofficialapiv1]:
try: song_info = parser(search_result=search_result, request_overrides=request_overrides)
except: continue
if song_info.with_valid_download_url: break
if not song_info.with_valid_download_url: continue
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_parsebyalbum'''
def _parsebyalbum(self, search_results, song_infos: list = [], request_overrides: dict = None, progress: Progress = None):
request_overrides = request_overrides or {}
for search_result in search_results['response']['docs']:
if (not isinstance(search_result, dict)) or ('id' not in search_result): continue
download_results, page_size, tracks, unique_track_ids = [], 200, [], set()
song_info = SongInfo(
raw_data={'search': search_result, 'download': download_results, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(search_result.get('nickname')),
album=f"{search_result.get('tracks', 0) or 0} Episodes", ext=None, file_size=None, identifier=search_result['id'], duration='-:-:-', lyric=None, cover_url=safeextractfromdict(search_result, ['cover_path'], None),
download_url=None, download_url_status={}, episodes=[],
)
num_pages = math.ceil(int(search_result.get('tracks', 0) or 0) / page_size)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{num_pages}) pages downloaded in album {search_result['id']}", total=num_pages)
for page_num_idx, page_num in enumerate(range(1, num_pages + 1)):
if page_num_idx > 0:
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({page_num_idx}/{num_pages}) pages downloaded in album {search_result['id']}")
try: resp = self.get(f'http://mobile.ximalaya.com/mobile/v1/album/track?albumId={search_result["id"]}&pageId={page_num}&pageSize={page_size}&isAsc=true', **request_overrides)
except: continue
download_results.append(resp2json(resp=resp))
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({page_num_idx+1}/{num_pages}) pages downloaded in album {search_result['id']}")
for download_result in download_results:
for track in (safeextractfromdict(download_result, ['data', 'list'], []) or []):
if not isinstance(track, dict) or not track.get('trackId'): continue
if track.get('trackId') in unique_track_ids: continue
unique_track_ids.add(track.get('trackId'))
tracks.append(track)
download_album_pid = progress.add_task(f"{self.source}._parsebyalbum >>> (0/{len(tracks)}) episodes completed in album {search_result['id']}", total=len(tracks))
for track_idx, track in enumerate(tracks):
if track_idx > 0:
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx}/{len(tracks)}) episodes completed in album {search_result['id']}")
eps_info = SongInfo(source=self.source)
for parser in [self._parsewithcggapi, self._parsewithofficialapiv1]:
try: eps_info = parser(search_result=track, request_overrides=request_overrides)
except: continue
if eps_info.with_valid_download_url: break
if not eps_info.with_valid_download_url: continue
song_info.episodes.append(eps_info)
progress.advance(download_album_pid, 1)
progress.update(download_album_pid, description=f"{self.source}._parsebyalbum >>> ({track_idx+1}/{len(tracks)}) episodes completed in album {search_result['id']}")
if not song_info.with_valid_download_url: continue
try: song_info.duration_s = sum([eps.duration_s for eps in song_info.episodes]); song_info.duration = seconds2hms(song_info.duration_s)
except Exception: pass
try: song_info.file_size_bytes = sum([eps.file_size_bytes for eps in song_info.episodes]); song_info.file_size = byte2mb(song_info.file_size_bytes)
except Exception: pass
song_infos.append(song_info)
if self.strict_limit_search_size_per_page and len(song_infos) >= self.search_size_per_page: break
return song_infos
'''_search'''
@usesearchheaderscookies
def _search(self, keyword: str = '', search_url: str = '', request_overrides: dict = None, song_infos: list = [], progress: Progress = None, progress_id: int = 0):
# init
request_overrides = request_overrides or {}
# successful
try:
# --search results
(resp := self.get(search_url, **request_overrides)).raise_for_status()
search_results = resp2json(resp)
# --parse based on search type
search_type = parse_qs(urlparse(search_url).query, keep_blank_values=True).get('core')[0]
parsers = {'album': self._parsebyalbum, 'track': self._parsebytrack}
parsers[search_type](search_results, song_infos=song_infos, request_overrides=request_overrides, progress=progress)
# --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