Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Lazy source registry to avoid importing every source at module import time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
from ..utils import BaseModuleBuilder
|
||||
from .base import BaseMusicClient
|
||||
|
||||
|
||||
CLIENT_IMPORT_PATHS = {
|
||||
# Platforms in Greater China
|
||||
"QQMusicClient": ("musicdl.modules.sources.qq", "QQMusicClient"),
|
||||
"KugouMusicClient": ("musicdl.modules.sources.kugou", "KugouMusicClient"),
|
||||
"StreetVoiceMusicClient": ("musicdl.modules.sources.streetvoice", "StreetVoiceMusicClient"),
|
||||
"SodaMusicClient": ("musicdl.modules.sources.soda", "SodaMusicClient"),
|
||||
"FiveSingMusicClient": ("musicdl.modules.sources.fivesing", "FiveSingMusicClient"),
|
||||
"NeteaseMusicClient": ("musicdl.modules.sources.netease", "NeteaseMusicClient"),
|
||||
"QianqianMusicClient": ("musicdl.modules.sources.qianqian", "QianqianMusicClient"),
|
||||
"MiguMusicClient": ("musicdl.modules.sources.migu", "MiguMusicClient"),
|
||||
"KuwoMusicClient": ("musicdl.modules.sources.kuwo", "KuwoMusicClient"),
|
||||
"BilibiliMusicClient": ("musicdl.modules.sources.bilibili", "BilibiliMusicClient"),
|
||||
# Global Streaming / Indie
|
||||
"YouTubeMusicClient": ("musicdl.modules.sources.youtube", "YouTubeMusicClient"),
|
||||
"JooxMusicClient": ("musicdl.modules.sources.joox", "JooxMusicClient"),
|
||||
"AppleMusicClient": ("musicdl.modules.sources.apple", "AppleMusicClient"),
|
||||
"JamendoMusicClient": ("musicdl.modules.sources.jamendo", "JamendoMusicClient"),
|
||||
"SoundCloudMusicClient": ("musicdl.modules.sources.soundcloud", "SoundCloudMusicClient"),
|
||||
"DeezerMusicClient": ("musicdl.modules.sources.deezer", "DeezerMusicClient"),
|
||||
"QobuzMusicClient": ("musicdl.modules.sources.qobuz", "QobuzMusicClient"),
|
||||
"SpotifyMusicClient": ("musicdl.modules.sources.spotify", "SpotifyMusicClient"),
|
||||
"TIDALMusicClient": ("musicdl.modules.sources.tidal", "TIDALMusicClient"),
|
||||
# Audio / Radio
|
||||
"XimalayaMusicClient": ("musicdl.modules.audiobooks.ximalaya", "XimalayaMusicClient"),
|
||||
"LizhiMusicClient": ("musicdl.modules.audiobooks.lizhi", "LizhiMusicClient"),
|
||||
"QingtingMusicClient": ("musicdl.modules.audiobooks.qingting", "QingtingMusicClient"),
|
||||
"LRTSMusicClient": ("musicdl.modules.audiobooks.lrts", "LRTSMusicClient"),
|
||||
# Aggregators / Multi-Source Gateways
|
||||
"MP3JuiceMusicClient": ("musicdl.modules.common.mp3juice", "MP3JuiceMusicClient"),
|
||||
"TuneHubMusicClient": ("musicdl.modules.common.tunehub", "TuneHubMusicClient"),
|
||||
"GDStudioMusicClient": ("musicdl.modules.common.gdstudio", "GDStudioMusicClient"),
|
||||
"MyFreeMP3MusicClient": ("musicdl.modules.common.myfreemp3", "MyFreeMP3MusicClient"),
|
||||
"JBSouMusicClient": ("musicdl.modules.common.jbsou", "JBSouMusicClient"),
|
||||
# Unofficial Download Sites / Scrapers
|
||||
"MituMusicClient": ("musicdl.modules.thirdpartysites.mitu", "MituMusicClient"),
|
||||
"BuguyyMusicClient": ("musicdl.modules.thirdpartysites.buguyy", "BuguyyMusicClient"),
|
||||
"GequbaoMusicClient": ("musicdl.modules.thirdpartysites.gequbao", "GequbaoMusicClient"),
|
||||
"YinyuedaoMusicClient": ("musicdl.modules.thirdpartysites.yinyuedao", "YinyuedaoMusicClient"),
|
||||
"FLMP3MusicClient": ("musicdl.modules.thirdpartysites.flmp3", "FLMP3MusicClient"),
|
||||
"FangpiMusicClient": ("musicdl.modules.thirdpartysites.fangpi", "FangpiMusicClient"),
|
||||
"FiveSongMusicClient": ("musicdl.modules.thirdpartysites.fivesong", "FiveSongMusicClient"),
|
||||
"KKWSMusicClient": ("musicdl.modules.thirdpartysites.kkws", "KKWSMusicClient"),
|
||||
"GequhaiMusicClient": ("musicdl.modules.thirdpartysites.gequhai", "GequhaiMusicClient"),
|
||||
"LivePOOMusicClient": ("musicdl.modules.thirdpartysites.livepoo", "LivePOOMusicClient"),
|
||||
"HTQYYMusicClient": ("musicdl.modules.thirdpartysites.htqyy", "HTQYYMusicClient"),
|
||||
"JCPOOMusicClient": ("musicdl.modules.thirdpartysites.jcpoo", "JCPOOMusicClient"),
|
||||
"TwoT58MusicClient": ("musicdl.modules.thirdpartysites.twot58", "TwoT58MusicClient"),
|
||||
"ZhuolinMusicClient": ("musicdl.modules.thirdpartysites.zhuolin", "ZhuolinMusicClient"),
|
||||
}
|
||||
|
||||
|
||||
def _load_client_class(client_name: str):
|
||||
module_path, attr_name = CLIENT_IMPORT_PATHS[client_name]
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, attr_name)
|
||||
|
||||
|
||||
def _build_client_factory(client_name: str):
|
||||
def factory(**kwargs):
|
||||
return _load_client_class(client_name)(**kwargs)
|
||||
|
||||
factory.__name__ = client_name
|
||||
return factory
|
||||
|
||||
|
||||
class MusicClientBuilder(BaseModuleBuilder):
|
||||
REGISTERED_MODULES = {
|
||||
client_name: _build_client_factory(client_name)
|
||||
for client_name in CLIENT_IMPORT_PATHS
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in CLIENT_IMPORT_PATHS:
|
||||
return _load_client_class(name)
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
BuildMusicClient = MusicClientBuilder().build
|
||||
|
||||
__all__ = ["BaseMusicClient", "BuildMusicClient", "MusicClientBuilder", *CLIENT_IMPORT_PATHS.keys()]
|
||||
@@ -0,0 +1,195 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of AppleMusicClient: https://music.apple.com/{geo}/new
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import os
|
||||
import copy
|
||||
import shutil
|
||||
from types import SimpleNamespace
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import APPLE_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils.appleutils import AppleMusicClientDownloadSongUtils, AppleMusicClientAPIUtils, AppleMusicClientItunesApiUtils, DownloadItem, SongCodec, RemuxMode
|
||||
from ..utils import touchdir, legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, usedownloadheaderscookies, useparseheaderscookies, hostmatchessuffix, obtainhostname, cleanlrc, SongInfo, SongInfoUtils, AudioLinkTester
|
||||
|
||||
|
||||
'''AppleMusicClient'''
|
||||
class AppleMusicClient(BaseMusicClient):
|
||||
source = 'AppleMusicClient'
|
||||
def __init__(self, use_wrapper: bool = False, wrapper_account_url: str = "http://127.0.0.1:30020/", language: str = "en-US", codec: str = None, wrapper_decrypt_ip: str = "127.0.0.1:10020", **kwargs):
|
||||
super(AppleMusicClient, self).__init__(**kwargs)
|
||||
self.apple_music_api, self.itunes_api, self.use_wrapper, self.wrapper_account_url, self.language, self.account_info, self.codec, self.wrapper_decrypt_ip = None, None, use_wrapper, wrapper_account_url, language, {}, codec, wrapper_decrypt_ip
|
||||
if self.codec is None: self.codec = SongCodec.ALAC if use_wrapper else SongCodec.AAC_LEGACY
|
||||
self.default_search_headers = {
|
||||
"accept": "*/*", "accept-language": "en-US", "origin": "https://music.apple.com", "priority": "u=1, i", "sec-fetch-site": "same-site", "sec-ch-ua-platform": '"Windows"',
|
||||
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-fetch-mode": "cors", "referer": "https://music.apple.com",
|
||||
"sec-fetch-dest": "empty", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
}
|
||||
self.default_parse_headers = {
|
||||
"accept": "*/*", "accept-language": "en-US", "origin": "https://music.apple.com", "priority": "u=1, i", "sec-fetch-site": "same-site", "sec-ch-ua-platform": '"Windows"',
|
||||
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-fetch-mode": "cors", "referer": "https://music.apple.com",
|
||||
"sec-fetch-dest": "empty", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.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/137.0.0.0 Safari/537.36"}
|
||||
self.default_headers = self.default_search_headers
|
||||
self._initsession()
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
request_overrides = request_overrides or {}
|
||||
if isinstance(song_info.download_url, str): return super()._download(song_info=song_info, request_overrides=request_overrides, downloaded_song_infos=downloaded_song_infos, progress=progress, song_progress_id=song_progress_id, auto_supplement_song=auto_supplement_song)
|
||||
try:
|
||||
touchdir(song_info.work_dir); tmp_dir = f'apple_id_{str(song_info.identifier)}'; touchdir(tmp_dir); download_item: DownloadItem = song_info.download_url
|
||||
progress.update(song_progress_id, total=1, kind='overall'); progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Downloading)")
|
||||
AppleMusicClientDownloadSongUtils.download(download_item=download_item, work_dir=tmp_dir, silent=self.disable_print, codec=self.codec, wrapper_decrypt_ip=self.wrapper_decrypt_ip, artist=song_info.singers, use_wrapper=self.use_wrapper, remux_mode=RemuxMode.FFMPEG); shutil.move(download_item.staged_path, song_info.save_path)
|
||||
progress.update(song_progress_id, total=os.path.getsize(song_info.save_path), kind='download'); progress.advance(song_progress_id, os.path.getsize(song_info.save_path))
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Success)")
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info)); shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
return downloaded_song_infos
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
if (not self.default_cookies or 'media-user-token' not in self.default_cookies) and (not self.use_wrapper): self.logger_handle.warning(f'{self.source}._constructsearchurls >>> both "media-user-token" and "use_wrapper" are not configured, so song downloads are restricted and only the preview portion of the track can be downloaded.')
|
||||
if self.use_wrapper and (not self.apple_music_api): self.apple_music_api = AppleMusicClientAPIUtils.createfromwrapper(wrapper_account_url=self.wrapper_account_url, request_overrides=request_overrides, language=self.language)
|
||||
elif self.default_cookies and ('media-user-token' in self.default_cookies) and (not self.apple_music_api): self.apple_music_api = AppleMusicClientAPIUtils.createfromnetscapecookies(cookies=self.default_cookies, request_overrides=request_overrides, language=self.language)
|
||||
if self.apple_music_api and (not self.itunes_api): self.itunes_api = AppleMusicClientItunesApiUtils(storefront=self.apple_music_api.storefront, language=self.apple_music_api.language)
|
||||
if self.apple_music_api and ('authorization' not in self.default_headers): self.default_search_headers = copy.deepcopy(self.apple_music_api.client.headers); self.default_headers = self.default_search_headers; self._initsession(); self.account_info = self.apple_music_api.account_info
|
||||
elif ('authorization' not in self.default_headers): virtual_client = SimpleNamespace(client=self.session, language=self.language); self.default_search_headers.update({"authorization": f"Bearer {AppleMusicClientAPIUtils.gettoken(virtual_client, request_overrides=request_overrides)}"}); self.default_headers = self.default_search_headers; self._initsession()
|
||||
# search rules
|
||||
default_rule = {
|
||||
"groups": "song", "l": "en-US", "offset": "0", "term": keyword, "types": "activities,albums,apple-curators,artists,curators,editorial-items,music-movies,music-videos,playlists,record-labels,songs,stations,tv-episodes,uploaded-videos", "art[url]": "f", "extend": "artistUrl", "fields[albums]": "artistName,artistUrl,artwork,contentRating,editorialArtwork,editorialNotes,name,playParams,releaseDate,url,trackCount", "fields[artists]": "url,name,artwork",
|
||||
"format[resources]": "map", "include[editorial-items]": "contents", "include[songs]": "artists", "limit": "10", "omit[resource]": "autos", "platform": "web", "relate[albums]": "artists", "relate[editorial-items]": "contents", "relate[songs]": "albums", "types": "activities,albums,apple-curators,artists,curators,music-movies,music-videos,playlists,songs,stations,tv-episodes,uploaded-videos", "with": "lyrics,serverBubbles",
|
||||
}
|
||||
default_rule.update(rule)
|
||||
geo = safeextractfromdict(self.account_info, ['meta', 'subscription', 'storefront'], 'us')
|
||||
# construct search urls based on search rules
|
||||
base_url = f'https://amp-api-edge.music.apple.com/v1/catalog/{geo}/search?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithnonvipofficialapiv1'''
|
||||
def _parsewithnonvipofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))) or (search_result.get('type') not in {'songs'}): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
if not (download_url := safeextractfromdict(search_result, ['attributes', 'previews', 0, 'url'], '')) or not str(download_url).startswith('http'): return song_info
|
||||
try: duration_in_secs = float(safeextractfromdict(search_result, ['attributes', 'durationInMillis'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': {}, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(search_result, ['attributes', 'name'], None)), singers=legalizestring(safeextractfromdict(search_result, ['attributes', 'artistName'], None)), album=legalizestring(safeextractfromdict(search_result, ['attributes', 'albumName'], None)),
|
||||
ext=str(download_url).split('?')[0].split('.')[-1], file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=safeextractfromdict(search_result, ['attributes', 'artwork', 'url'], 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']
|
||||
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.cover_url and song_info.cover_url.startswith('http'): song_info.cover_url = song_info.cover_url.format(w=600, h=600, f='jpg')
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithvipofficialapiv1'''
|
||||
def _parsewithvipofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac, codec = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source), self.codec
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))) or (search_result.get('type') not in {'songs'}): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
geo = safeextractfromdict(self.account_info, ['meta', 'subscription', 'storefront'], 'us')
|
||||
(resp := self.get(f'https://amp-api.music.apple.com/v1/catalog/{geo}/songs/{song_id}', params={"extend": "extendedAssetUrls", "include": "lyrics,albums"}, **request_overrides)).raise_for_status()
|
||||
download_item: DownloadItem = AppleMusicClientDownloadSongUtils.getdownloaditem(song_metadata=(download_result := resp2json(resp=resp))['data'][0], playlist_metadata=None, codec=codec, apple_music_api=self.apple_music_api, itunes_api=self.itunes_api, request_overrides=request_overrides, use_wrapper=self.use_wrapper)
|
||||
(resp := self.get(download_item.stream_info.audio_track.stream_url, **request_overrides)).raise_for_status()
|
||||
try: duration_in_secs = float(safeextractfromdict(search_result, ['attributes', 'durationInMillis'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(search_result, ['attributes', 'name'], None)), singers=legalizestring(safeextractfromdict(search_result, ['attributes', 'artistName'], None)), album=legalizestring(safeextractfromdict(search_result, ['attributes', 'albumName'], None)),
|
||||
ext=download_item.stream_info.file_format.value, file_size='HLS', identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=cleanlrc(str(download_item.lyrics.synced)) or 'NULL', cover_url=safeextractfromdict(search_result, ['attributes', 'artwork', 'url'], None), download_url=download_item, download_url_status={'ok': True},
|
||||
)
|
||||
if song_info.cover_url and song_info.cover_url.startswith('http'): song_info.cover_url = song_info.cover_url.format(w=600, h=600, f='jpg')
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for song_key, search_result in dict(resp2json(resp)['resources']['songs']).items():
|
||||
search_result['song_key'] = song_key
|
||||
# --parse with non-vip official apis
|
||||
if (not self.default_cookies or 'media-user-token' not in self.default_cookies) and (not self.use_wrapper):
|
||||
try: song_info = self._parsewithnonvipofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: continue
|
||||
# --parse with vip official apis
|
||||
else:
|
||||
try: song_info = self._parsewithvipofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: continue
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, APPLE_MUSIC_HOSTS)): return song_infos
|
||||
if (not self.default_cookies or 'media-user-token' not in self.default_cookies) and (not self.use_wrapper): raise PermissionError(f'{self.source}.parseplaylist >>> both "media-user-token" and "use_wrapper" are not configured, so musicdl does not have permission to parse Apple Music playlists.')
|
||||
if self.use_wrapper and (not self.apple_music_api): self.apple_music_api = AppleMusicClientAPIUtils.createfromwrapper(wrapper_account_url=self.wrapper_account_url, request_overrides=request_overrides, language=self.language)
|
||||
elif self.default_cookies and ('media-user-token' in self.default_cookies) and (not self.apple_music_api): self.apple_music_api = AppleMusicClientAPIUtils.createfromnetscapecookies(cookies=self.default_cookies, request_overrides=request_overrides, language=self.language)
|
||||
self.apple_music_api = AppleMusicClientAPIUtils.createfromnetscapecookies(cookies=self.default_cookies, request_overrides=request_overrides, language=self.language)
|
||||
if self.apple_music_api and (not self.itunes_api): self.itunes_api = AppleMusicClientItunesApiUtils(storefront=self.apple_music_api.storefront, language=self.apple_music_api.language)
|
||||
if self.apple_music_api and ('authorization' not in self.default_headers): self.default_search_headers = copy.deepcopy(self.apple_music_api.client.headers); self.default_headers = self.default_search_headers; self._initsession(); self.account_info = self.apple_music_api.account_info
|
||||
elif ('authorization' not in self.default_headers): virtual_client = SimpleNamespace(client=self.session, language=self.language); self.default_search_headers.update({"authorization": f"Bearer {AppleMusicClientAPIUtils.gettoken(virtual_client, request_overrides=request_overrides)}"}); self.default_headers = self.default_search_headers; self._initsession()
|
||||
# get tracks in playlist
|
||||
playlist_result = self.apple_music_api.getplaylist(playlist_id, request_overrides=request_overrides)
|
||||
tracks_in_playlist = safeextractfromdict(playlist_result, ['data', 0, 'relationships', 'tracks', 'data'], []) or []
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithvipofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['data', 0, 'attributes', 'name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,293 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of BaseMusicClient
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import random
|
||||
import pickle
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from rich.text import Text
|
||||
from itertools import chain
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from pathvalidate import sanitize_filepath
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn, MofNCompleteColumn, ProgressColumn, Task
|
||||
from ..utils import LoggerHandle, AudioLinkTester, SongInfo, SongInfoUtils, HLSDownloader, touchdir, usedownloadheaderscookies, usesearchheaderscookies, useparseheaderscookies, cookies2dict, cookies2string, shortenpathsinsonginfos, optionalimport, optionalimportfrom
|
||||
|
||||
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
def build_user_agent() -> str:
|
||||
try:
|
||||
user_agent_cls = optionalimportfrom("fake_useragent", "UserAgent")
|
||||
if user_agent_cls is not None:
|
||||
return user_agent_cls().random
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_USER_AGENT
|
||||
|
||||
|
||||
'''AudioAwareColumn'''
|
||||
class AudioAwareColumn(ProgressColumn):
|
||||
def __init__(self):
|
||||
super(AudioAwareColumn, self).__init__()
|
||||
self._download_col = DownloadColumn()
|
||||
'''render'''
|
||||
def render(self, task: Task):
|
||||
kind = task.fields.get("kind", "download")
|
||||
if kind == "overall": completed = int(task.completed); total = int(task.total) if task.total is not None else 0; return Text(f"{completed}/{total} audios")
|
||||
elif kind == "hls": completed = int(task.completed); total = int(task.total) if task.total is not None else 0; return Text(f"{completed}/{total} segments")
|
||||
else: return self._download_col.render(task)
|
||||
|
||||
|
||||
'''BaseMusicClient'''
|
||||
class BaseMusicClient():
|
||||
source = 'BaseMusicClient'
|
||||
def __init__(self, search_size_per_source: int = 5, auto_set_proxies: bool = False, random_update_ua: bool = False, enable_search_curl_cffi: bool = False, enable_parse_curl_cffi: bool = False, enable_download_curl_cffi: bool = False, maintain_session: bool = False, logger_handle: LoggerHandle = None, disable_print: bool = False, work_dir: str = 'musicdl_outputs',
|
||||
max_retries: int = 3, freeproxy_settings: dict = None, default_search_cookies: dict | str = None, default_download_cookies: dict | str = None, default_parse_cookies: dict | str = None, strict_limit_search_size_per_page: bool = True, search_size_per_page: int = 10, quark_parser_config: dict = None):
|
||||
# set up work dir
|
||||
touchdir(work_dir)
|
||||
# set attributes
|
||||
self.search_size_per_source = search_size_per_source
|
||||
self.auto_set_proxies = auto_set_proxies
|
||||
self.random_update_ua = random_update_ua
|
||||
self.max_retries = max_retries
|
||||
self.maintain_session = maintain_session
|
||||
self.logger_handle = logger_handle if logger_handle else LoggerHandle()
|
||||
self.disable_print = disable_print
|
||||
self.work_dir = work_dir
|
||||
self.freeproxy_settings = freeproxy_settings or {}
|
||||
self.quark_parser_config = quark_parser_config or {}
|
||||
self.default_search_cookies = cookies2dict(default_search_cookies); self.default_download_cookies = cookies2dict(default_download_cookies); self.default_parse_cookies = cookies2dict(default_parse_cookies); self.default_cookies = self.default_search_cookies
|
||||
self.search_size_per_page = min(search_size_per_source, search_size_per_page); self.strict_limit_search_size_per_page = strict_limit_search_size_per_page
|
||||
self.enable_search_curl_cffi = enable_search_curl_cffi; self.enable_download_curl_cffi = enable_download_curl_cffi; self.enable_parse_curl_cffi = enable_parse_curl_cffi; self.enable_curl_cffi = self.enable_search_curl_cffi
|
||||
self.cc_impersonates = self._listccimpersonates() if (enable_search_curl_cffi or enable_download_curl_cffi) else None
|
||||
# init requests.Session
|
||||
self.default_search_headers = {'User-Agent': build_user_agent()}; self.default_download_headers = {'User-Agent': build_user_agent()}; self.default_parse_headers = {'User-Agent': build_user_agent()}
|
||||
self.quark_default_download_headers = {
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36 Core/1.94.225.400 QQBrowser/12.2.5544.400', 'origin': 'https://pan.quark.cn',
|
||||
'referer': 'https://pan.quark.cn/', 'accept-language': 'zh-CN,zh;q=0.9', 'cookie': cookies2string(self.quark_parser_config.get('cookies', '')),
|
||||
}
|
||||
self.quark_default_download_cookies = {} # placeholder, useless now
|
||||
self.default_headers = self.default_search_headers
|
||||
self._initsession()
|
||||
# proxied_session_client
|
||||
freeproxy = optionalimportfrom('freeproxy', 'freeproxy')
|
||||
(default_freeproxy_settings := dict(disable_print=True, proxy_sources=['ProxiflyProxiedSession'], max_tries=20, init_proxied_session_cfg={})).update(self.freeproxy_settings)
|
||||
self.proxied_session_client = freeproxy.ProxiedSessionClient(**default_freeproxy_settings) if auto_set_proxies else None
|
||||
'''_listccimpersonates'''
|
||||
def _listccimpersonates(self):
|
||||
curl_cffi = optionalimport('curl_cffi')
|
||||
root = Path(curl_cffi.__file__).resolve().parent
|
||||
exts = {".py", ".so", ".pyd", ".dll", ".dylib"}
|
||||
pat = re.compile(rb"\b(?:chrome|edge|safari|firefox|tor)(?:\d+[a-z_]*|_android|_ios)?\b")
|
||||
return sorted({m.decode("utf-8", "ignore") for p in root.rglob("*") if p.suffix in exts for m in pat.findall(p.read_bytes())})
|
||||
'''_initsession'''
|
||||
def _initsession(self):
|
||||
if self.maintain_session and getattr(self, 'session', None) and getattr(self, 'audio_link_tester', None) and getattr(self, 'quark_audio_link_tester', None): return
|
||||
curl_cffi = optionalimport('curl_cffi')
|
||||
self.session = requests.Session() if not self.enable_curl_cffi else curl_cffi.requests.Session()
|
||||
self.session.headers = self.default_headers
|
||||
self.audio_link_tester = AudioLinkTester(headers=copy.deepcopy(self.default_download_headers), cookies=copy.deepcopy(self.default_download_cookies))
|
||||
self.quark_audio_link_tester = AudioLinkTester(headers=copy.deepcopy(self.quark_default_download_headers), cookies=copy.deepcopy(self.quark_default_download_cookies))
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
raise NotImplementedError('not to be implemented')
|
||||
'''_constructuniqueworkdir'''
|
||||
def _constructuniqueworkdir(self, keyword: str, sort_by_search_kwd_and_time: bool = True):
|
||||
time_stamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
||||
touchdir((work_dir := sanitize_filepath(os.path.join(self.work_dir, self.source, f'{time_stamp} {keyword}') if sort_by_search_kwd_and_time else os.path.join(self.work_dir, self.source))))
|
||||
return work_dir
|
||||
'''_removeduplicates'''
|
||||
def _removeduplicates(self, song_infos: list[SongInfo] = None) -> list[SongInfo]:
|
||||
unique_song_infos, identifiers = [], set()
|
||||
for song_info in song_infos:
|
||||
if song_info.identifier in identifiers: continue
|
||||
identifiers.add(song_info.identifier); unique_song_infos.append(song_info)
|
||||
return unique_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):
|
||||
raise NotImplementedError('not be implemented')
|
||||
'''search'''
|
||||
@usesearchheaderscookies
|
||||
def search(self, keyword: str, num_threadings: int = 5, request_overrides: dict = None, rule: dict = None, main_process_context: Progress = None, main_progress_id: int = None, main_progress_lock: Lock = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
# logging
|
||||
self.logger_handle.info(f'Start to search music files using {self.source}.', disable_print=self.disable_print)
|
||||
# construct search urls
|
||||
search_urls = self._constructsearchurls(keyword=keyword, rule=rule, request_overrides=request_overrides)
|
||||
# multi threadings for searching music files
|
||||
if main_process_context is None: owns_progress = True; main_process_context = Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10); main_process_context.__enter__()
|
||||
else: owns_progress = False
|
||||
if main_progress_lock is None: main_progress_lock = Lock()
|
||||
with main_progress_lock:
|
||||
progress_id = main_process_context.add_task(f"{self.source}.search >>> completed (0/{len(search_urls)})", total=len(search_urls))
|
||||
if main_progress_id is not None:
|
||||
cur_total = main_process_context.tasks[main_progress_id].total or 0
|
||||
main_process_context.update(main_progress_id, total=cur_total + len(search_urls))
|
||||
main_process_context.update(main_progress_id, description=f"Search from sources >>> completed ({int(main_process_context.tasks[main_progress_id].completed)}/{cur_total + len(search_urls)})")
|
||||
song_infos, submitted_tasks = {}, []
|
||||
with ThreadPoolExecutor(max_workers=num_threadings) as pool:
|
||||
for search_url_idx, search_url in enumerate(search_urls):
|
||||
song_infos[str(search_url_idx)] = []
|
||||
submitted_tasks.append(pool.submit(self._search, keyword, search_url, request_overrides, song_infos[str(search_url_idx)], main_process_context, progress_id))
|
||||
for future in as_completed(submitted_tasks):
|
||||
future.result()
|
||||
with main_progress_lock:
|
||||
main_process_context.advance(progress_id, 1)
|
||||
num_searched_urls = int(main_process_context.tasks[progress_id].completed)
|
||||
main_process_context.update(progress_id, description=f"{self.source}.search >>> completed ({num_searched_urls}/{len(search_urls)})")
|
||||
if main_progress_id is None: continue
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"Search from sources >>> completed ({int(main_process_context.tasks[main_progress_id].completed)}/{int(main_process_context.tasks[main_progress_id].total or 0)})")
|
||||
song_infos = list(chain.from_iterable(song_infos.values())); song_infos = self._removeduplicates(song_infos=song_infos)
|
||||
work_dir = self._constructuniqueworkdir(keyword=keyword)
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# logging
|
||||
if len(song_infos) > 0:
|
||||
work_dir_to_song_info, work_dir = defaultdict(list), ', '.join(list(set([str(s.work_dir) for s in song_infos])))
|
||||
for s in song_infos: s.work_dir = str(s.work_dir); work_dir_to_song_info[s.work_dir].append(s.todict())
|
||||
for w, items in work_dir_to_song_info.items(): touchdir(w); self._savetopkl(items, os.path.join(w, "search_results.pkl"))
|
||||
else:
|
||||
work_dir = self.work_dir
|
||||
self.logger_handle.info(f'Finished searching music files using {self.source}. Search results have been saved to {work_dir}, valid items: {len(song_infos)}.', disable_print=self.disable_print)
|
||||
if owns_progress: main_process_context.__exit__(None, None, None)
|
||||
# return
|
||||
return song_infos
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list[SongInfo] = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
request_overrides = copy.deepcopy(request_overrides or {})
|
||||
if song_info.protocol.upper() in {'HLS'}:
|
||||
try:
|
||||
hls_downloader = HLSDownloader(
|
||||
output_dir=song_info.work_dir, proxies=request_overrides.pop('proxies', {}) or self._autosetproxies(), headers=song_info.default_download_headers or request_overrides.pop('headers', {}) or self.default_headers, cookies=request_overrides.pop('cookies', {}) or self.default_cookies,
|
||||
logger_handle=self.logger_handle, verify_tls=request_overrides.pop('verify', True), timeout=request_overrides.pop('timeout', (10, 30)), disable_print=self.disable_print, request_overrides=request_overrides
|
||||
)
|
||||
hls_downloader.download(song_info.download_url, song_info.save_path, quality='best', keep_segments=False, temp_subdir=str(song_info.identifier), progress=progress, progress_id=song_progress_id)
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
elif song_info.protocol.upper() in {'HTTP'} and song_info.downloaded_contents:
|
||||
try:
|
||||
touchdir(song_info.work_dir)
|
||||
total_size = song_info.downloaded_contents.__sizeof__()
|
||||
progress.update(song_progress_id, total=total_size)
|
||||
with open(song_info.save_path, "wb") as fp: fp.write(song_info.downloaded_contents)
|
||||
progress.advance(song_progress_id, total_size)
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Success)")
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
elif song_info.protocol.upper() in {'HTTP'}:
|
||||
try:
|
||||
touchdir(song_info.work_dir)
|
||||
if song_info.default_download_headers: request_overrides['headers'] = song_info.default_download_headers
|
||||
with self.get(song_info.download_url, stream=True, **request_overrides) as resp:
|
||||
resp.raise_for_status()
|
||||
total_size, chunk_size, downloaded_size = int(resp.headers.get('content-length', 0)), song_info.get('chunk_size', 1024), 0
|
||||
progress.update(song_progress_id, total=total_size)
|
||||
with open(song_info.save_path, "wb") as fp:
|
||||
for chunk in resp.iter_content(chunk_size=chunk_size):
|
||||
if not chunk: continue
|
||||
fp.write(chunk); downloaded_size = downloaded_size + len(chunk)
|
||||
if total_size > 0: downloading_text = "%0.2fMB/%0.2fMB" % (downloaded_size / 1024 / 1024, total_size / 1024 / 1024)
|
||||
else: progress.update(song_progress_id, total=downloaded_size); downloading_text = "%0.2fMB/%0.2fMB" % (downloaded_size / 1024 / 1024, downloaded_size / 1024 / 1024)
|
||||
progress.advance(song_progress_id, len(chunk))
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Downloading: {downloading_text})")
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Success)")
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
return downloaded_song_infos
|
||||
'''download'''
|
||||
@usedownloadheaderscookies
|
||||
def download(self, song_infos: list[SongInfo], num_threadings: int = 5, request_overrides: dict = None, auto_supplement_song: bool = True):
|
||||
# init
|
||||
request_overrides = request_overrides or {}; shortenpathsinsonginfos(song_infos=song_infos)
|
||||
# logging
|
||||
self.logger_handle.info(f'Start to download music files using {self.source}.', disable_print=self.disable_print)
|
||||
# multi threadings for downloading music files
|
||||
columns = [SpinnerColumn(), TextColumn("{task.description}"), BarColumn(bar_width=None), TaskProgressColumn(), AudioAwareColumn(), TransferSpeedColumn(), TimeRemainingColumn()]
|
||||
with Progress(*columns, refresh_per_second=20, expand=True) as progress:
|
||||
songs_progress_id = progress.add_task(f"{self.source}.download >>> completed (0/{len(song_infos)})", total=len(song_infos), kind='overall')
|
||||
song_progress_ids, downloaded_song_infos, submitted_tasks = [], [], []
|
||||
for _, song_info in enumerate(song_infos):
|
||||
desc = f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Preparing)"
|
||||
song_progress_ids.append(progress.add_task(desc, total=None, kind='download'))
|
||||
with ThreadPoolExecutor(max_workers=num_threadings) as pool:
|
||||
for song_progress_id, song_info in zip(song_progress_ids, song_infos): submitted_tasks.append(pool.submit(self._download, song_info, request_overrides, downloaded_song_infos, progress, song_progress_id, auto_supplement_song))
|
||||
for _ in as_completed(submitted_tasks):
|
||||
progress.advance(songs_progress_id, 1)
|
||||
num_downloaded_songs = int(progress.tasks[songs_progress_id].completed)
|
||||
progress.update(songs_progress_id, description=f"{self.source}.download >>> completed ({num_downloaded_songs}/{len(song_infos)})")
|
||||
# logging
|
||||
if len(downloaded_song_infos) > 0:
|
||||
work_dir_to_song_info, work_dir = defaultdict(list), ', '.join(list(set([str(s.work_dir) for s in downloaded_song_infos])))
|
||||
for s in downloaded_song_infos: s.work_dir = str(s.work_dir); work_dir_to_song_info[s.work_dir].append(s.todict())
|
||||
for w, items in work_dir_to_song_info.items(): touchdir(w); self._savetopkl(items, os.path.join(w, "download_results.pkl"))
|
||||
else:
|
||||
work_dir = self.work_dir
|
||||
self.logger_handle.info(f'Finished downloading music files using {self.source}. Download results have been saved to {work_dir}, valid downloads: {len(downloaded_song_infos)}.', disable_print=self.disable_print)
|
||||
# return
|
||||
return downloaded_song_infos
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
raise NotImplementedError(f'Not supported now to parse playlist from {self.source}')
|
||||
'''_autosetproxies'''
|
||||
def _autosetproxies(self):
|
||||
if not self.auto_set_proxies: return {}
|
||||
try: proxies = self.proxied_session_client.getrandomproxy()
|
||||
except Exception as err: self.logger_handle.error(f'{self.source}._autosetproxies >>> freeproxy lib failed to auto fetch proxies (Error: {err})', disable_print=self.disable_print); proxies = {}
|
||||
return proxies
|
||||
'''get'''
|
||||
def get(self, url, **kwargs):
|
||||
if 'cookies' not in kwargs: kwargs['cookies'] = self.default_cookies
|
||||
if 'timeout' not in kwargs: kwargs['timeout'] = (10, 30)
|
||||
if 'impersonate' not in kwargs and self.enable_curl_cffi: kwargs['impersonate'] = random.choice(self.cc_impersonates)
|
||||
resp = None
|
||||
for _ in range(self.max_retries):
|
||||
if not self.maintain_session:
|
||||
self._initsession()
|
||||
if self.random_update_ua: self.session.headers.update({'User-Agent': build_user_agent()})
|
||||
proxies = kwargs.pop('proxies', None) or self._autosetproxies()
|
||||
try: (resp := self.session.get(url, proxies=proxies, **kwargs)).raise_for_status()
|
||||
except Exception as err: self.logger_handle.error(f'{self.source}.get >>> {url} (Error: {err}; status={getattr(locals().get("resp"), "status_code", None)})', disable_print=self.disable_print); continue
|
||||
return resp
|
||||
return resp
|
||||
'''post'''
|
||||
def post(self, url, **kwargs):
|
||||
if 'cookies' not in kwargs: kwargs['cookies'] = self.default_cookies
|
||||
if 'timeout' not in kwargs: kwargs['timeout'] = (10, 30)
|
||||
if 'impersonate' not in kwargs and self.enable_curl_cffi: kwargs['impersonate'] = random.choice(self.cc_impersonates)
|
||||
resp = None
|
||||
for _ in range(self.max_retries):
|
||||
if not self.maintain_session:
|
||||
self._initsession()
|
||||
if self.random_update_ua: self.session.headers.update({'User-Agent': build_user_agent()})
|
||||
proxies = kwargs.pop('proxies', None) or self._autosetproxies()
|
||||
try: (resp := self.session.post(url, proxies=proxies, **kwargs)).raise_for_status()
|
||||
except Exception as err: self.logger_handle.error(f'{self.source}.post >>> {url} (Error: {err}; status={getattr(locals().get("resp"), "status_code", None)})', disable_print=self.disable_print); continue
|
||||
return resp
|
||||
return resp
|
||||
'''_savetopkl'''
|
||||
def _savetopkl(self, data, file_path, auto_sanitize=True):
|
||||
if auto_sanitize: file_path = sanitize_filepath(file_path)
|
||||
with open(file_path, 'wb') as fp: pickle.dump(data, fp)
|
||||
@@ -0,0 +1,123 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of BilibiliMusicClient: https://www.bilibili.com/audio/home/?type=9
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import copy
|
||||
from .base import BaseMusicClient
|
||||
from urllib.parse import urlencode
|
||||
from rich.progress import Progress
|
||||
from ..utils import legalizestring, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
'''BilibiliMusicClient'''
|
||||
class BilibiliMusicClient(BaseMusicClient):
|
||||
source = 'BilibiliMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(BilibiliMusicClient, 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/121.0.0.0 Safari/537.36 Edg/121.0.0.0", "Sec-Ch-Ua": '"Not A(Brand";v="99", "Microsoft Edge";v="121", "Chromium";v="121"', "Referer": "https://www.bilibili.com/", "Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Accept-Encoding": "gzip, deflate", "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-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5", "Sec-Ch-Ua-Platform": '"Windows"', "Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
self.default_download_headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", "Sec-Ch-Ua": '"Not A(Brand";v="99", "Microsoft Edge";v="121", "Chromium";v="121"', "Referer": "https://www.bilibili.com/", "Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Accept-Encoding": "gzip, deflate", "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-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5", "Sec-Ch-Ua-Platform": '"Windows"', "Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
self.default_headers = self.default_search_headers
|
||||
default_cookies = {
|
||||
"buvid3": "2E109C72-251F-3827-FA8E-921FA0D7EC5291319infoc", "b_nut": "1676213591", "i-wanna-go-back": "-1", "_uuid": "2B2D7A6C-8310C-1167-F548-2F1095A6E93F290252infoc", "buvid4": "31696B5F-BB23-8F2B-3310-8B3C55FB49D491966-023021222-WcoPnBbwgLUAZ6TJuAUN8Q%3D%3D", "CURRENT_FNVAL": "4048", "nostalgia_conf": "-1",
|
||||
"bili_jct": "4c583b61b86b16d812a7804078828688", "sid": "8dt1ioao", "bili_ticket": "eyJhbGciOiJIUzI1NiIsImtpZCI6InMwMyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDQ2MjUzNjAsImlhdCI6MTcwNDM2NjEwMCwicGx0IjotMX0.4E-V4K2y452cy6eexwY2x_q3-xgcNF2qtugddiuF8d4", "rpdid": "|(JY))RmR~|u0J'uY~YkuJ~Ru", "buvid_fp_plain": "undefined",
|
||||
"b_ut": "5", "DedeUserID__ckMd5": "66450f2302095cc5", "DedeUserID": "520271156", "FEED_LIVE_VERSION": "V8", "header_theme_version": "CLOSE", "CURRENT_QUALITY": "80", "enable_web_push": "DISABLE", "buvid_fp": "52ad4773acad74caefdb23875d5217cd", "PVID": "1", "CURRENT_PID": "418c8490-cadb-11ed-b23b-dd640f2e1c14",
|
||||
"home_feed_column": "5", "SESSDATA": "8036f42c%2C1719895843%2C19675%2A12CjATThdxG8TyQ2panBpBQcmT0gDKjexwc-zXNGiMnIQ2I9oLVmOiE9YkLao2_aawEhoSVlhGY05PVjVkZWM0T042Z2hZRXBOdElYWXhJa3RpVmZ0M3NvcWw1N0tPcGRVSmRoOVNQZnNHT1JHS05yR1Y1MUFLX3RXeXVJa3NjbEVBQkUxRVN6RFRRIIEC", "fingerprint": "847f1839b443252d91ff0df7465fa8d9",
|
||||
"hit-dyn-v2": "1", "LIVE_BUVID": "AUTO8716766313471956", "hit-new-style-dyn": "1", "bili_ticket_expires": "1704625300", "browser_resolution": "1912-924", "bp_video_offset_520271156": "883089613008142344",
|
||||
}
|
||||
self.default_search_cookies = self.default_search_cookies or copy.deepcopy(default_cookies)
|
||||
self.default_download_cookies = self.default_download_cookies or copy.deepcopy(default_cookies)
|
||||
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 = {'__refresh__': 'true', '_extra': '', 'page': 1, 'page_size': self.search_size_per_page, 'platform': 'pc', 'highlight': '1', 'context': '', 'single_column': '0', 'keyword': keyword, 'category_id': '', 'search_type': 'video', 'dynamic_offset': '0', 'preload': 'true', 'com2co': 'true'}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://api.bilibili.com/x/web-interface/search/type?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['page_size'] = page_size
|
||||
page_rule['page'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not search_result.get('id')) or (not (song_bvid := search_result.get('bvid'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
(resp := self.get(f"https://api.bilibili.com/x/web-interface/view?bvid={song_bvid}", **request_overrides)).raise_for_status()
|
||||
pages, root_title, song_info = resp2json(resp=resp)['data']['pages'], resp2json(resp=resp)['data']['title'], []
|
||||
episodes = [(page["cid"], page["part"]) for page in pages if isinstance(page, dict) and page.get("cid") and page.get("part")]
|
||||
for cid, episode_name in episodes:
|
||||
try: (resp := self.get(f"https://api.bilibili.com/x/player/playurl?fnval=16&bvid={song_bvid}&cid={cid}")).raise_for_status()
|
||||
except Exception: continue
|
||||
download_result = resp2json(resp=resp)
|
||||
audios = [a for a in (safeextractfromdict(download_result, ['data', 'dash', 'flac', 'audio'], []) or []) if isinstance(a, dict) and (a.get('baseUrl') or a.get('base_url') or a.get('backupUrl') or a.get('backup_url'))]
|
||||
if not audios: audios = [a for a in (safeextractfromdict(download_result, ['data', 'dash', 'dolby', 'audio'], []) or []) if isinstance(a, dict) and (a.get('baseUrl') or a.get('base_url') or a.get('backupUrl') or a.get('backup_url'))]
|
||||
if not audios: audios = [a for a in (safeextractfromdict(download_result, ['data', 'dash', 'audio'], []) or []) if isinstance(a, dict) and (a.get('baseUrl') or a.get('base_url') or a.get('backupUrl') or a.get('backup_url'))]
|
||||
if not audios: continue
|
||||
audios_sorted = sorted(audios, key=lambda x: (x.get("bandwidth", 0) or 0, x.get("filesize", 0) or 0), reverse=True)
|
||||
if not (download_url := audios_sorted[0].get('baseUrl') or audios_sorted[0].get('base_url') or audios_sorted[0].get('backupUrl') or audios_sorted[0].get('backup_url')): continue
|
||||
if isinstance(download_url, list): download_url = download_url[0]
|
||||
eps_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(episode_name if episode_name == root_title else f'{root_title}-{episode_name}'), singers=legalizestring(search_result.get('author')),
|
||||
album=legalizestring(str(song_bvid)), ext='m4a', file_size=None, identifier=cid, duration_s=safeextractfromdict(download_result, ['data', 'dash', 'duration'], 0), duration=seconds2hms(safeextractfromdict(download_result, ['data', 'dash', 'duration'], 0)),
|
||||
lyric=None, cover_url=search_result.get('pic'), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
||||
)
|
||||
if eps_info.cover_url and (not eps_info.cover_url.startswith('http')): eps_info.cover_url = f'https:{eps_info.cover_url}'
|
||||
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']; eps_info.ext = eps_info.download_url_status['probe_status']['ext']
|
||||
if eps_info.ext in {'m4s', 'mp4'}: eps_info.ext = 'm4a'
|
||||
if (eps_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS) and (eps_info.download_url_status['probe_status']['ext'] in AudioLinkTester.VALID_AUDIO_EXTS): eps_info.ext = eps_info.download_url_status['probe_status']['ext']
|
||||
elif (eps_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): eps_info.ext = 'mp3'
|
||||
if eps_info.with_valid_download_url: song_info.append(eps_info)
|
||||
if self.strict_limit_search_size_per_page and len(song_info) >= self.search_size_per_page: break
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['data']['result']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if isinstance(song_info, list) and (not song_info): continue
|
||||
if isinstance(song_info, SongInfo) and (not song_info.with_valid_download_url): continue
|
||||
if isinstance(song_info, list): song_infos.extend(song_info)
|
||||
elif isinstance(song_info, SongInfo): 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: song_infos = 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,219 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of DeezerMusicClient: https://www.deezer.com/us/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import os
|
||||
import copy
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import DEEZER_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from ..utils.deezerutils import DeezerMusicClientUtils
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import replacefile, touchdir, legalizestring, resp2json, seconds2hms, usesearchheaderscookies, usedownloadheaderscookies, safeextractfromdict, extractdurationsecondsfromlrc, useparseheaderscookies, obtainhostname, hostmatchessuffix, byte2mb, cleanlrc, SongInfo, AudioLinkTester, SongInfoUtils, LyricSearchClient
|
||||
|
||||
|
||||
'''DeezerMusicClient'''
|
||||
class DeezerMusicClient(BaseMusicClient):
|
||||
source = 'DeezerMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
kwargs['maintain_session'] = True
|
||||
super(DeezerMusicClient, self).__init__(**kwargs)
|
||||
if self.default_search_cookies: assert "arl" in self.default_search_cookies, '"arl" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#deezer-music-download'
|
||||
if self.default_parse_cookies: assert "arl" in self.default_parse_cookies, '"arl" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#deezer-music-download'
|
||||
if self.default_download_cookies: assert "arl" in self.default_download_cookies, '"arl" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#deezer-music-download'
|
||||
self.default_search_headers = {
|
||||
'Pragma': 'no-cache', 'Origin': 'https://www.deezer.com', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'en-US,en;q=0.9', 'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0', 'DNT': '1',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Accept': '*/*', 'Cache-Control': 'no-cache', 'X-Requested-With': 'XMLHttpRequest', 'Connection': 'keep-alive', 'Referer': 'https://www.deezer.com/login',
|
||||
}
|
||||
self.default_parse_headers = {
|
||||
'Pragma': 'no-cache', 'Origin': 'https://www.deezer.com', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'en-US,en;q=0.9', 'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0', 'DNT': '1',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Accept': '*/*', 'Cache-Control': 'no-cache', 'X-Requested-With': 'XMLHttpRequest', 'Connection': 'keep-alive', 'Referer': 'https://www.deezer.com/login',
|
||||
}
|
||||
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.auth_info = {}
|
||||
self._initsession()
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
super()._download(song_info=song_info, request_overrides=request_overrides, downloaded_song_infos=[], progress=progress, song_progress_id=song_progress_id, auto_supplement_song=False)
|
||||
if DeezerMusicClientUtils.IS_ENCRYPTED_RPATTERN.search(song_info.download_url) is None: downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info)); return downloaded_song_infos
|
||||
output_filepath = (output_filepath := Path(song_info.save_path)).parent / f'{output_filepath.stem}.decrypt'
|
||||
blowfish_key = DeezerMusicClientUtils.generateblowfishkey(str(song_info.raw_data.get('id')))
|
||||
DeezerMusicClientUtils.decryptdownloadedaudiofile(src_path=str(song_info.save_path), dst_path=str(output_filepath), blowfish_key=blowfish_key)
|
||||
replacefile(str(output_filepath), str(song_info.save_path))
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
return downloaded_song_infos
|
||||
'''_setauthinfo'''
|
||||
def _setauthinfo(self, request_overrides: dict = None):
|
||||
if self.auth_info: return
|
||||
request_overrides = request_overrides or {}
|
||||
(resp := self.post('http://www.deezer.com/ajax/gw-light.php', params={'api_version': "1.0", 'api_token': 'null', 'input': '3', 'method': 'deezer.getUserData'}, **request_overrides)).raise_for_status()
|
||||
self.auth_info = resp2json(resp=resp)
|
||||
return self.auth_info
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}; self._setauthinfo(request_overrides=request_overrides)
|
||||
if (not self.default_cookies or 'arl' not in self.default_cookies): self.logger_handle.warning(f'{self.source}._constructsearchurls >>> cookies are not configured, so song downloads are restricted and only the preview portion of the track can be downloaded.')
|
||||
# search rules
|
||||
default_rule = {'q': keyword, 'index': 1, 'limit': 20}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://api.deezer.com/search/track?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['index'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, is_fallback_retry: bool = False, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source); self._setauthinfo(request_overrides=request_overrides)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := (search_result.get('id') or search_result.get('SNG_ID')))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
# --track details
|
||||
try: (resp := self.post('http://www.deezer.com/ajax/gw-light.php', params={'api_version': "1.0", 'api_token': safeextractfromdict(self.auth_info, ['results', 'checkForm'], None), 'input': '3', 'method': 'song.getData'}, json={'SNG_ID': song_id}, **request_overrides)).raise_for_status(); assert not safeextractfromdict((download_result := resp2json(resp=resp)), ['error'], None)
|
||||
except: (resp := self.get(f'https://api.deezer.com/track/{song_id}', **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
|
||||
# --necessary information
|
||||
license_token = safeextractfromdict(self.auth_info, ['results', 'USER', 'OPTIONS', 'license_token'], None)
|
||||
track_token = safeextractfromdict(download_result, ['results', 'TRACK_TOKEN'], None) or download_result.get('track_token')
|
||||
track_hash = safeextractfromdict(download_result, ['results', 'MD5_ORIGIN'], None) or download_result.get('md5_origin')
|
||||
media_version = safeextractfromdict(download_result, ['results', 'MEDIA_VERSION'], None) or download_result.get('media_version')
|
||||
fallback_song_id = safeextractfromdict(download_result, ['results', 'FALLBACK', 'SNG_ID'], None) or safeextractfromdict(download_result, ['fallback', 'sng_id'], None) or safeextractfromdict(download_result, ['fallback', 'id'], None)
|
||||
# --fetch from high to low qualities
|
||||
for quality in DeezerMusicClientUtils.MUSIC_QUALITIES:
|
||||
if not track_token or not license_token: continue
|
||||
try: (resp := self.post("https://media.deezer.com/v1/get_url", json={'license_token': license_token, 'media': [{'type': "FULL", "formats": [{"cipher": "BF_CBC_STRIPE", "format": quality}]}], 'track_tokens': [track_token,]}, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_result['track_details'] = resp2json(resp=resp); candidate_results = safeextractfromdict(download_result['track_details'], ['data', 0, 'media', 0, 'sources'], []) or []
|
||||
if not (candidate_results := [c for c in candidate_results if isinstance(c, dict) and c.get('url') and str(c.get('url')).startswith('http')]): continue
|
||||
for candidate_result in candidate_results:
|
||||
try: file_size_bytes = float(safeextractfromdict(download_result['track_details'], ['data', 0, 'media', 0, 'filesize'], 0))
|
||||
except Exception: file_size_bytes = 0
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['results', 'DURATION'], 0) or download_result.get('duration', 0))
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'id': song_id}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['results', 'SNG_TITLE'], None) or download_result.get('title')), singers=legalizestring(safeextractfromdict(download_result, ['results', 'ART_NAME'], None) or safeextractfromdict(download_result, ['artist', 'name'], None)),
|
||||
album=legalizestring(safeextractfromdict(download_result, ['results', 'ALB_TITLE'], None) or safeextractfromdict(download_result, ['album', 'title'], None)), ext=str(candidate_result['url']).split('?')[0].split('.')[-1], file_size_bytes=int(file_size_bytes), file_size=byte2mb(file_size_bytes), identifier=str(song_id), duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None,
|
||||
cover_url=DeezerMusicClientUtils.getcoverurl(safeextractfromdict(download_result, ['results', 'ALB_PICTURE'], None)) or safeextractfromdict(download_result, ['album', 'cover_xl'], None), download_url=candidate_result['url'], download_url_status=self.audio_link_tester.test(candidate_result['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']
|
||||
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 song_info.with_valid_download_url: break
|
||||
# --fallback id retry if possible
|
||||
if (not song_info.with_valid_download_url) and (not is_fallback_retry) and fallback_song_id: return self._parsewithofficialapiv1(search_result={'id': fallback_song_id}, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, lossless_quality_definitions=lossless_quality_definitions, is_fallback_retry=True, request_overrides=request_overrides)
|
||||
# --manually construct download url, pretty sketchy
|
||||
if (not song_info.with_valid_download_url) and (media_version is not None) and (track_hash is not None):
|
||||
download_url = DeezerMusicClientUtils.getencryptedfileurl(song_id, track_hash=track_hash, media_version=media_version)
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['results', 'DURATION'], 0) or download_result.get('duration', 0))
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'id': song_id}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['results', 'SNG_TITLE'], None) or download_result.get('title')), singers=legalizestring(safeextractfromdict(download_result, ['results', 'ART_NAME'], None) or safeextractfromdict(download_result, ['artist', 'name'], None)), album=legalizestring(safeextractfromdict(download_result, ['results', 'ALB_TITLE'], None) or safeextractfromdict(download_result, ['album', 'title'], None)),
|
||||
ext='mp3', file_size_bytes=None, file_size=None, identifier=str(song_id), duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=DeezerMusicClientUtils.getcoverurl(safeextractfromdict(download_result, ['results', 'ALB_PICTURE'], None)) or safeextractfromdict(download_result, ['album', 'cover_xl'], 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.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'
|
||||
# --use preview audio link
|
||||
if (not song_info.with_valid_download_url):
|
||||
download_url = safeextractfromdict(download_result, ['results', 'MEDIA', 0, 'HREF'], None) or download_result.get('preview')
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['results', 'DURATION'], 0) or download_result.get('duration', 0))
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'id': song_id}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['results', 'SNG_TITLE'], None) or download_result.get('title')), singers=legalizestring(safeextractfromdict(download_result, ['results', 'ART_NAME'], None) or safeextractfromdict(download_result, ['artist', 'name'], None)), album=legalizestring(safeextractfromdict(download_result, ['results', 'ALB_TITLE'], None) or safeextractfromdict(download_result, ['album', 'title'], None)),
|
||||
ext=str(download_url).split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=str(song_id), duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=DeezerMusicClientUtils.getcoverurl(safeextractfromdict(download_result, ['results', 'ALB_PICTURE'], None)) or safeextractfromdict(download_result, ['album', 'cover_xl'], 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']
|
||||
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 not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
try: (resp := self.post('https://auth.deezer.com/login/renew?jo=p&rto=c&i=c', **request_overrides)).raise_for_status(); headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Origin": "https://www.deezer.com", "Referer": "https://www.deezer.com/", "Authorization": f"Bearer {resp2json(resp=resp)['jwt']}"}; payload = {"operationName": "GetLyrics", "variables": {"trackId": str(song_id)}, "query": "query GetLyrics($trackId: String!) { track(trackId: $trackId) { id lyrics { id text ...SynchronizedWordByWordLines ...SynchronizedLines licence copyright writers __typename } __typename } } fragment SynchronizedWordByWordLines on Lyrics { id synchronizedWordByWordLines { start end words { start end word __typename } __typename } __typename } fragment SynchronizedLines on Lyrics { id synchronizedLines { lrcTimestamp line lineTranslated milliseconds duration __typename } __typename }"}; (resp := requests.post("https://pipe.deezer.com/api", headers=headers, json=payload, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp); lyric = cleanlrc(DeezerMusicClientUtils.covert2lrclyrics(lyric_result['data']['track']['lyrics'])) or 'NULL'
|
||||
except Exception: lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
if not song_info.duration or song_info.duration == '-:-:-': song_info.duration = seconds2hms(extractdurationsecondsfromlrc(song_info.lyric))
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}; self._setauthinfo(request_overrides=request_overrides)
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.get(search_url, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp=resp)['data']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}; self._setauthinfo(request_overrides=request_overrides)
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, DEEZER_MUSIC_HOSTS)): return song_infos
|
||||
if (not self.default_cookies or 'arl' not in self.default_cookies): self.logger_handle.warning(f'{self.source}.parseplaylist >>> cookies are not configured, so song downloads are restricted and only the preview portion of the track can be downloaded.')
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, page_size, playlist_result_first = [], 1, 500, {}
|
||||
while True:
|
||||
payload = {'playlist_id': playlist_id, 'start': (page - 1) * page_size, 'tab': 0, 'header': True, 'lang': 'de', 'nb': page_size}
|
||||
try: (resp := self.post(f"https://www.deezer.com/ajax/gw-light.php?method=deezer.pagePlaylist&input=3&api_version=1.0&api_token={safeextractfromdict(self.auth_info, ['results', 'checkForm'], None)}", json=payload, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['results', 'SONGS', 'data'], []): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['results', 'SONGS', 'data'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['results', 'DATA', 'NB_SONG'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["SNG_ID"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['results', 'DATA', 'TITLE'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,147 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of FiveSingMusicClient: https://5sing.kugou.com/index.html
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
from bs4 import BeautifulSoup
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import FIVESING_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse, urljoin
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, byte2mb, resp2json, usesearchheaderscookies, safeextractfromdict, extractdurationsecondsfromlrc, seconds2hms, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
'''FiveSingMusicClient'''
|
||||
class FiveSingMusicClient(BaseMusicClient):
|
||||
source = 'FiveSingMusicClient'
|
||||
MUSIC_QUALITIES = ['sq', 'hq', 'lq']
|
||||
def __init__(self, **kwargs):
|
||||
super(FiveSingMusicClient, 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/145.0.0.0 Safari/537.36", "Referer": "https://5sing.kugou.com/"}
|
||||
self.default_parse_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", "Referer": "https://5sing.kugou.com/"}
|
||||
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, 'sort': 1, 'page': 1, 'filter': 0, 'type': 0}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'http://search.5sing.kugou.com/home/json?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['page'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('songId'))) or (not (song_type := search_result.get('typeEname'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
(resp := self.get('http://mobileapi.5sing.kugou.com/song/getSongUrl', params={'songid': str(song_id), 'songtype': song_type}, **request_overrides)).raise_for_status()
|
||||
download_result: dict = resp2json(resp)
|
||||
for quality in FiveSingMusicClient.MUSIC_QUALITIES:
|
||||
download_url = safeextractfromdict(download_result, ['data', f'{quality}url'], '') or safeextractfromdict(download_result, ['data', f'{quality}url_backup'], '')
|
||||
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('songName')), singers=legalizestring(search_result.get('singer')), album='NULL', ext=safeextractfromdict(download_result, ['data', f'{quality}ext'], 'mp3'),
|
||||
file_size_bytes=safeextractfromdict(download_result, ['data', f'{quality}size'], 0), file_size=byte2mb(safeextractfromdict(download_result, ['data', f'{quality}size'], 0)), identifier=song_id, duration='-:-:-', lyric=None, cover_url=safeextractfromdict(download_result, ['data', 'user', 'I'], 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.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: return song_info
|
||||
# supplement lyric results
|
||||
params = {'songid': str(song_id), 'songtype': song_type, 'songfields': '', 'userfields': ''}
|
||||
try: (resp := self.get('http://mobileapi.5sing.kugou.com/song/newget', params=params, **request_overrides)).raise_for_status(); lyric = cleanlrc(safeextractfromdict((lyric_result := resp2json(resp)), ['data', 'dynamicWords'], '')) or 'NULL'
|
||||
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
|
||||
song_info.album = legalizestring(safeextractfromdict(lyric_result, ['data', 'albumName'], None))
|
||||
song_info.cover_url = safeextractfromdict(lyric_result, ['data', 'user', 'I'], None)
|
||||
if not song_info.duration or song_info.duration == '-:-:-': song_info.duration = seconds2hms(extractdurationsecondsfromlrc(song_info.lyric))
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['list']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, FIVESING_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
try: playlist_result = resp2json(self.get(f'http://mobileapi.5sing.kugou.com/song/getsonglist?id={playlist_id}&songfields=ID,user', **request_overrides))
|
||||
except Exception: playlist_result = dict()
|
||||
soup, playlist_result['song_list'] = BeautifulSoup(self.get(playlist_url, **request_overrides).text, "lxml"), []
|
||||
for li in soup.select("ul.dj_songitems > li"):
|
||||
title_a, singer_a = li.select_one("span.s_title a.songlist_hits"), li.select_one("span.s_soner a")
|
||||
info_node = li.select_one("a.paly_btn[songinfo]") or li.select_one("a.add_btn[songinfo]")
|
||||
kind, song_id = ((m.group(1), m.group(2)) if (m := re.match(r"([a-z]+)\$(\d+)", str(info_node["songinfo"]))) else (None, None)) if info_node and info_node.has_attr("songinfo") else (None, None)
|
||||
coll_btn = li.select_one("a.coll_btn[songid]")
|
||||
song_id, kind = (coll_btn.get("songid"), {"1": "yc", "2": "fc", "3": "bz"}.get(coll_btn.get("songkind"))) if (not song_id) and coll_btn else (song_id, kind)
|
||||
playlist_result['song_list'].append({"songName": title_a.get_text(strip=True) if title_a else None, "songId": song_id, "typeEname": kind, "song_url": urljoin("http://5sing.kugou.com", title_a["href"]) if title_a and title_a.has_attr("href") else None, "singer": singer_a.get_text(strip=True) if singer_a else None, "singer_url": urljoin("http://5sing.kugou.com", singer_a["href"]) if singer_a and singer_a.has_attr("href") else None})
|
||||
tracks_in_playlist = playlist_result['song_list']
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['data', 'T'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,155 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of JamendoMusicClient: https://www.jamendo.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import re
|
||||
import os
|
||||
import copy
|
||||
import random
|
||||
import hashlib
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import JAMENDO_MUSIC_HOSTS
|
||||
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, parse_qs
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''JamendoMusicClient'''
|
||||
class JamendoMusicClient(BaseMusicClient):
|
||||
source = 'JamendoMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(JamendoMusicClient, self).__init__(**kwargs)
|
||||
self.default_search_headers = {
|
||||
"referer": "https://www.jamendo.com/search?q=musicdl", "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", "sec-fetch-site": "same-origin",
|
||||
"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", "x-jam-version": "4rkl5f", "x-jam-call": "$536ab7feabd2404af7b6e54b4db74039734b58b3*0.5310391483096057~", "x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
self.default_parse_headers = {
|
||||
"referer": "https://www.jamendo.com/search?q=musicdl", "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", "sec-fetch-site": "same-origin",
|
||||
"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", "x-jam-version": "4rkl5f", "x-jam-call": "$536ab7feabd2404af7b6e54b4db74039734b58b3*0.5310391483096057~", "x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
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 = {'query': keyword, 'type': 'track', 'limit': self.search_size_per_source, 'identities': 'www', 'offset': 0}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://www.jamendo.com/api/search?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
make_xjam_call_func = lambda path='/api/tracks': f"${hashlib.sha1((path + (rand := str(random.random()))).encode('utf-8')).hexdigest()}*{rand}~"
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
(headers := copy.deepcopy(self.default_headers))['x-jam-call'] = make_xjam_call_func(path='/api/tracks')
|
||||
try: (resp := self.get('https://www.jamendo.com/api/tracks?', headers=headers, params={'id[]': song_id}, **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)[0]
|
||||
except: download_result = {}
|
||||
(headers := copy.deepcopy(self.default_headers))['x-jam-call'] = make_xjam_call_func(path='/api/artists')
|
||||
artist_id = safeextractfromdict(search_result, ['artist', 'id'], None) or search_result.get('artistId') or download_result.get('artistId')
|
||||
if not safeextractfromdict(search_result, ['artist', 'name'], None): download_result['artist'] = resp2json(self.get('https://www.jamendo.com/api/artists?', headers=headers, params={'id[]': artist_id}, **request_overrides))[0]
|
||||
(headers := copy.deepcopy(self.default_headers))['x-jam-call'] = make_xjam_call_func(path='/api/albums')
|
||||
album_id = safeextractfromdict(search_result, ['album', 'id'], None) or search_result.get('albumId') or download_result.get('albumId')
|
||||
if not legalizestring(safeextractfromdict(search_result, ['album', 'name'], None)): download_result['album'] = resp2json(self.get('https://www.jamendo.com/api/albums?', headers=headers, params={'id[]': album_id}, **request_overrides))[0]
|
||||
candidate_urls = [safeextractfromdict(download_result, list(path), None) for path in [('stream', 'flac'), ('download', 'flac'), ('stream', 'mp33'), ('stream', 'mp32'), ('download', 'mp3'), ('stream', 'mp3'), ('stream', 'ogg'), ('download', 'ogg')]]
|
||||
candidate_urls = [c for c in candidate_urls if c and str(c).startswith('http')]
|
||||
if candidate_urls: candidate_urls = [urlunsplit((*urlsplit(candidate_urls[0])[:3], urlencode([(k, 'flac' if k == 'format' else v) for k, v in parse_qsl(urlsplit(str(candidate_urls[0])).query, keep_blank_values=True)]), urlsplit(str(candidate_urls[0])).fragment))] + candidate_urls
|
||||
if not candidate_urls: candidate_urls = [safeextractfromdict(search_result, list(path), None) for path in [('download', 'mp3'), ('stream', 'mp3'), ('download', 'ogg'), ('stream', 'ogg')]]
|
||||
if not (candidate_urls := [c for c in candidate_urls if c and str(c).startswith('http')]): return song_info
|
||||
for download_url in ([f"https://prod-1.storage.jamendo.com/download/track/{song_id}/flac/"] + candidate_urls):
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name') or download_result.get('name')), singers=legalizestring(safeextractfromdict(search_result, ['artist', 'name'], None) or safeextractfromdict(download_result, ['artist', 'name'], None)), album=legalizestring(safeextractfromdict(search_result, ['album', 'name'], None) or safeextractfromdict(download_result, ['album', 'name'], None)),
|
||||
ext=('mp3' if (f := (parse_qs(urlsplit(str(download_url)).query).get('format', [None])[0] or re.search(r'/download/track/\d+/([^/]+)/', urlsplit(str(download_url)).path).group(1))).startswith('mp3') else f), file_size_bytes=None, file_size=None, identifier=song_id, duration_s=search_result.get('duration') or download_result.get('duration', 0), duration=seconds2hms(search_result.get('duration') or download_result.get('duration')), lyric=download_result.get('lyrics') or 'NULL',
|
||||
cover_url=f"https://usercontent.jamendo.com?type=album&id={album_id}&width=300&trackid={song_id}", download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
||||
)
|
||||
if song_info.lyric and song_info.lyric not in {'NULL'}: song_info.lyric = cleanlrc(song_info.lyric.replace('<br />', '\n'))
|
||||
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
|
||||
# supplement lyric results
|
||||
lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}
|
||||
make_xjam_call_func = lambda path='/api/search': f"${hashlib.sha1((path + (rand := str(random.random()))).encode('utf-8')).hexdigest()}*{rand}~"
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(headers := copy.deepcopy(self.default_headers))['x-jam-call'] = make_xjam_call_func(path='/api/search')
|
||||
(resp := self.get(search_url, headers=headers, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp):
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = re.search(r'/playlist/([^/]+)', playlist_url).group(1) if re.search(r'/playlist/([^/]+)', playlist_url) else None, []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, JAMENDO_MUSIC_HOSTS)): return song_infos
|
||||
make_xjam_call_func = lambda path='/api/playlists': f"${hashlib.sha1((path + (rand := str(random.random()))).encode('utf-8')).hexdigest()}*{rand}~"
|
||||
# get tracks in playlist
|
||||
(headers := copy.deepcopy(self.default_headers))['x-jam-call'] = make_xjam_call_func(path='/api/playlists')
|
||||
playlist_result = self.get('https://www.jamendo.com/api/playlists?', headers=headers, params={'id[]': playlist_id}, **request_overrides)
|
||||
tracks_in_playlist = (playlist_result := resp2json(playlist_result)[0])['tracks']
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,149 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of JooxMusicClient: https://www.joox.com/intl
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
import base64
|
||||
import json_repair
|
||||
from bs4 import BeautifulSoup
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import JOOX_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, extractdurationsecondsfromlrc, cookies2string, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''JooxMusicClient'''
|
||||
class JooxMusicClient(BaseMusicClient):
|
||||
source = 'JooxMusicClient'
|
||||
FUZZY_MUSIC_QUALITIES = [
|
||||
"master_tapeUrl", "master_tapeURL", "master_tape_url", "masterTapeUrl", "masterTapeURL", "rMasterTapeUrl", "rMasterTapeURL", "hiresUrl", "hiresURL", "hires_url", "hiResUrl", "hiResURL", "rHiresUrl", "rHiResUrl", "flacUrl", "flacURL", "flac_url", "rFlacUrl", "rflacUrl", "rFLACUrl", "apeUrl", "apeURL", "ape_url", "rApeUrl", "rapeUrl", "stereo_atmosUrl", "stereo_atmosURL", "stereo_atmos_url", "stereoAtmosUrl", "stereoAtmosURL", "atmosUrl", "atmosURL", "atmos_url", "rStereoAtmosUrl", "rAtmosUrl", "dolby448Url", "dolby448URL", "dolby448_url", "rDolby448Url",
|
||||
"rDolby448URL", "dolby256Url", "dolby256URL", "dolby256_url", "rDolby256Url", "rDolby256URL", "r320Url", "r320url", "r320_url", "320Url", "320URL", "320_url", "url320", "mp3320Url", "mp3_320_url", "highUrl", "high_url", "r320oggUrl", "r320OggUrl", "r320OggURL", "r320_ogg_url", "320oggUrl", "320OggUrl", "ogg320Url", "ogg_320_url", "r192oggUrl", "r192OggUrl", "r192OggURL", "r192_ogg_url", "192oggUrl", "192OggUrl", "ogg192Url", "ogg_192_url", "r192k_mnacUrl", "r192k_mnacURL", "r192k_mnac_url", "r192kMnacUrl", "r192kMnacURL", "192k_mnacUrl", "192kMnacUrl",
|
||||
"mnac192Url", "mnac_192_url", "r192mnacUrl", "r192Url", "r192url", "r192_url", "192Url", "192URL", "192_url", "url192", "m4a192Url", "aac192Url", "aac_192_url", "mp3Url", "r128Url", "r128url", "r128_url", "128Url", "128URL", "128_url", "url128", "m4a128Url", "aac128Url", "mp3128Url", "m4aUrl", "r96Url", "r96url", "r96_url", "96Url", "96URL", "96_url", "url96", "r48Url", "r48url", "r48_url", "48Url", "48URL", "48_url", "url48", "r24Url", "r24url", "r24_url", "24Url", "24URL", "24_url", "url24", "lowUrl", "low_url", "previewUrl", "preview_url", "refrainUrl",
|
||||
"refrainURL", "refrain_url", "chorusUrl", "chorus_url", "clipUrl", "clip_url", "snippetUrl", "snippet_url", "trialUrl", "trial_url",
|
||||
]
|
||||
MUSIC_QUALITIES = [('r320Url', '320'), ('r192Url', '192'), ('mp3Url', '128'), ('m4aUrl', '96')]
|
||||
def __init__(self, **kwargs):
|
||||
super(JooxMusicClient, 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/145.0.0.0 Safari/537.36", "cookie": "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;", "x-forwarded-for": "36.73.34.109"}
|
||||
self.default_parse_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", "cookie": "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;", "x-forwarded-for": "36.73.34.109"}
|
||||
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"}
|
||||
if self.default_search_cookies: self.default_search_headers['cookie'] = cookies2string(self.default_search_cookies)
|
||||
if self.default_parse_cookies: self.default_parse_headers['cookie'] = cookies2string(self.default_parse_cookies)
|
||||
if self.default_download_cookies: self.default_download_headers['cookie'] = cookies2string(self.default_download_cookies)
|
||||
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 = {'country': 'hk', 'lang': 'zh_TW', 'key': keyword, 'type': '0'}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://cache.api.joox.com/openjoox/v2/search_type?'
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
search_urls = [base_url + urlencode(page_rule)]
|
||||
self.search_size_per_page = self.search_size_per_source
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, lang: str = 'zh_TW', country: str = 'hk', song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
(resp := self.get('https://api.joox.com/web-fcgi-bin/web_get_songinfo', params={'songid': song_id, 'lang': lang, 'country': country}, **request_overrides)).raise_for_status()
|
||||
download_result = json_repair.loads(resp.text.removeprefix('MusicInfoCallback(')[:-1])
|
||||
candidate_results: list[dict] = [{'quality': fmq, 'url': download_result.get(fmq)} for fmq in JooxMusicClient.FUZZY_MUSIC_QUALITIES if download_result.get(fmq)]
|
||||
for candidate_result in candidate_results:
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(', '.join([singer.get('name') for singer in (search_result.get('artist_list', []) or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(search_result.get('album_name')), ext=str(candidate_result['url']).split('?')[0].split('.')[-1], file_size=None, identifier=song_id, duration_s=download_result.get('minterval') or 0, duration=seconds2hms(download_result.get('minterval') or 0), lyric=None, cover_url=download_result.get('imgSrc'),
|
||||
download_url=candidate_result['url'], download_url_status=self.audio_link_tester.test(candidate_result['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']
|
||||
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: return song_info
|
||||
# supplement lyric results
|
||||
params = {'musicid': song_id, 'country': country, 'lang': lang}
|
||||
try: (resp := self.get('https://api.joox.com/web-fcgi-bin/web_lyric', params=params, **request_overrides)).raise_for_status(); lyric_result: dict = json_repair.loads(resp.text.replace('MusicJsonCallback(', '')[:-1]) or {}; lyric = cleanlrc(base64.b64decode(lyric_result.get('lyric', '')).decode('utf-8')) or 'NULL'
|
||||
except Exception: lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
if not song_info.duration or song_info.duration == '-:-:-': song_info.duration = seconds2hms(extractdurationsecondsfromlrc(song_info.lyric))
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}
|
||||
parsed_search_url = parse_qs(urlparse(search_url).query, keep_blank_values=True)
|
||||
lang, country = parsed_search_url['lang'][0], parsed_search_url['country'][0]
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.get(search_url, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp=resp)['tracks']:
|
||||
if isinstance(search_result, list): search_result = search_result[0]
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, lang=lang, country=country, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, lang, country = request_overrides or {}, 'zh_TW', 'hk'
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, JOOX_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
(resp := self.get(playlist_url, **request_overrides)).raise_for_status()
|
||||
script_tag = (BeautifulSoup(resp.text, 'lxml')).find('script', id='__NEXT_DATA__')
|
||||
if not script_tag: return song_infos
|
||||
tracks_in_playlist = (playlist_result := json_repair.loads(script_tag.string))['props']['pageProps']['allPlaylistTracks']['tracks']['items']
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, lang=lang, country=country, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result['props']['pageProps']['allPlaylistTracks'], ['name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,248 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of KugouMusicClient: http://www.kugou.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import time
|
||||
import random
|
||||
import base64
|
||||
import hashlib
|
||||
import warnings
|
||||
import json_repair
|
||||
from .base import BaseMusicClient
|
||||
from urllib.parse import urlencode
|
||||
from rich.progress import Progress
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import KUGOU_MUSIC_HOSTS
|
||||
from urllib.parse import urlparse, parse_qs, urljoin
|
||||
from ..utils.kugouutils import KugouMusicClientUtils, MUSIC_QUALITIES
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, byte2mb, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, optionalimport, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
'''KugouMusicClient'''
|
||||
class KugouMusicClient(BaseMusicClient):
|
||||
source = 'KugouMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(KugouMusicClient, 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/134.0.0.0 Safari/537.36'}
|
||||
self.default_parse_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'}
|
||||
self.default_download_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'}
|
||||
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 = {"format": "json", "keyword": keyword, "showtype": 1, "page": 1, "pagesize": 10}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'http://mobilecdn.kugou.com/api/v3/search/song?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['pagesize'] = page_size
|
||||
page_rule['page'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithcggapi'''
|
||||
def _parsewithcggapi(self, search_result: dict, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
curl_cffi, request_overrides, file_hash, MUSIC_QUALITIES = optionalimport('curl_cffi'), request_overrides or {}, search_result['hash'], ['lossless', 'exhigh', 'hires', 'standard', 'ogg']
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(str(meta.get('size', '0.00MB')).removesuffix('MB').strip()) if isinstance(meta, dict) else 0
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := curl_cffi.requests.get(f"https://music-api2.cenguigui.cn/?kg=&id={file_hash}&type=song&format=json&level={quality}", timeout=10, impersonate="chrome131", verify=False, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if 'data' not in (download_result := json_repair.loads(resp.text)) or (safe_obtain_filesize_func(download_result['data']) < 0.01): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['data', 'url'], '')) or not str(download_url).startswith('http'): continue
|
||||
try: duration_in_secs = search_result.get('duration') or (float(search_result.get('timelen', 0) or 0) / 1000)
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['data', 'artist'], None)), album=legalizestring(search_result.get('album_name') or safeextractfromdict(search_result, ['albuminfo', 'name'], None)),
|
||||
ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=str(safeextractfromdict(download_result, ['data', 'size'], "") or "0.00 MB").removesuffix('MB').strip() + ' MB', identifier=file_hash, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=safeextractfromdict(download_result, ['data', 'pic'], 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']
|
||||
if song_info.ext.startswith('m'): continue # encrypted format like mgg, skip by default
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithjbsouapi'''
|
||||
def _parsewithjbsouapi(self, search_result: dict, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
request_overrides, file_hash, base_url = request_overrides or {}, search_result['hash'], 'https://www.jbsou.cn/'
|
||||
headers = {
|
||||
"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", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", "origin": "https://www.jbsou.cn",
|
||||
"priority": "u=1, i", "referer": "https://www.jbsou.cn/", "sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "x-requested-with": "XMLHttpRequest",
|
||||
"sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "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",
|
||||
}
|
||||
# parse
|
||||
(resp := self.post('https://www.jbsou.cn/', data={'input': file_hash, 'filter': 'id', 'type': 'kugou', 'page': '1'}, headers=headers, **request_overrides)).raise_for_status()
|
||||
download_url = urljoin(base_url, safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 0, 'url'], ''))
|
||||
try: download_url = self.session.head(download_url, headers=headers, allow_redirects=True, **request_overrides).url
|
||||
except Exception: return SongInfo(source=self.source)
|
||||
if not download_url or not str(download_url).startswith('http'): return SongInfo(source=self.source)
|
||||
try: duration_in_secs = search_result.get('duration') or (float(search_result.get('timelen', 0) or 0) / 1000)
|
||||
except Exception: duration_in_secs = 0
|
||||
try: cover_url = self.session.head(urljoin(base_url, safeextractfromdict(download_result, ['data', 0, 'cover'], "")), headers=headers, allow_redirects=True, **request_overrides).url
|
||||
except Exception: cover_url = None
|
||||
if not cover_url: cover_url = safeextractfromdict(search_result, ['trans_param', 'union_cover'], None)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 0, 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['data', 0, 'artist'], None)),
|
||||
album=legalizestring(search_result.get('album_name') or safeextractfromdict(search_result, ['albuminfo', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size=None, identifier=str(file_hash), duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs),
|
||||
lyric='NULL', cover_url=cover_url, 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']
|
||||
if song_info.cover_url and isinstance(song_info.cover_url, str) and ('{size}' in song_info.cover_url): song_info.cover_url = song_info.cover_url.format(size=300)
|
||||
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 not song_info.with_valid_download_url: return song_info
|
||||
lyric_url = urljoin(base_url, safeextractfromdict(download_result, ['data', 0, 'lrc'], ""))
|
||||
try: (resp := self.get(lyric_url, headers=headers, allow_redirects=True, **request_overrides)).raise_for_status(); lyric = cleanlrc(resp.text)
|
||||
except Exception: lyric = 'NULL'
|
||||
song_info.lyric = lyric if (lyric and (lyric not in {'NULL'})) else song_info.lyric
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
if self.default_cookies or request_overrides.get('cookies'): return SongInfo(source=self.source)
|
||||
for imp_func in [self._parsewithcggapi, self._parsewithjbsouapi]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source)
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('hash'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
try: duration_in_secs = search_result.get('duration') or (float(search_result.get('timelen', 0) or 0) / 1000)
|
||||
except Exception: duration_in_secs = 0
|
||||
for quality in MUSIC_QUALITIES:
|
||||
if ('impersonate' not in (per_request_overrides := copy.deepcopy(request_overrides))) and self.enable_curl_cffi: per_request_overrides['impersonate'] = random.choice(self.cc_impersonates)
|
||||
per_request_overrides['proxies'] = per_request_overrides.pop('proxies', None) or self._autosetproxies()
|
||||
try: download_result: dict = KugouMusicClientUtils.getsongurl(self.session, hash_value=song_id, quality=quality, request_overrides=per_request_overrides, cookies=copy.deepcopy(per_request_overrides.pop('cookies', None) or self.default_cookies))
|
||||
except Exception: download_result, download_url = {}, None
|
||||
download_url = safeextractfromdict(download_result, ['url'], '') or safeextractfromdict(download_result, ['backupUrl'], '')
|
||||
if not download_url:
|
||||
md5_hex = hashlib.md5((str(song_id) + 'kgcloudv2').encode("utf-8")).hexdigest()
|
||||
try: (resp := self.get(f"https://trackercdn.kugou.com/i/v2/?cdnBackup=1&behavior=download&pid=1&cmd=21&appid=1001&hash={song_id}&key={md5_hex}", **request_overrides)).raise_for_status(); download_result: dict = resp2json(resp)
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict(download_result, ['url'], '') or safeextractfromdict(download_result, ['backup_url'], '') or safeextractfromdict(download_result, ['backupUrl'], '') or safeextractfromdict(download_result, ['mp3Url'], '') or safeextractfromdict(download_result, ['backupMp3Url'], '')
|
||||
if download_url and isinstance(download_url, (list, tuple)): download_url = list(download_url)[0]
|
||||
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('songname', None) or search_result.get('songname_original') or search_result.get('filename') or search_result.get('name')), singers=legalizestring(search_result.get('singername') or ', '.join([singer.get('name') for singer in (search_result.get('singerinfo') or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(search_result.get('album_name') or safeextractfromdict(search_result, ['albuminfo', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=download_result.get('fileSize', 0), file_size=byte2mb(download_result.get('fileSize', 0)), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=safeextractfromdict(search_result, ['trans_param', 'union_cover'], 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']
|
||||
if song_info.cover_url and isinstance(song_info.cover_url, str) and ('{size}' in song_info.cover_url): song_info.cover_url = song_info.cover_url.format(size=300)
|
||||
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_flac.with_valid_download_url and song_info_flac.largerthan(song_info): song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
params = {'keyword': search_result.get('filename', ''), 'duration': search_result.get('duration', '99999'), 'hash': song_id}
|
||||
try: (resp := self.get('http://lyrics.kugou.com/search', params=params, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp); (resp := self.get(f"http://lyrics.kugou.com/download?ver=1&client=pc&id={lyric_result['candidates'][0]['id']}&accesskey={lyric_result['candidates'][0]['accesskey']}&fmt=lrc&charset=utf8", **request_overrides)).raise_for_status(); lyric_result['lyrics.kugou.com/download'] = resp2json(resp=resp); lyric = cleanlrc(base64.b64decode(lyric_result['lyrics.kugou.com/download']['content']).decode('utf-8')) or 'NULL'
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['data']['info']:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlparse(playlist_url).query, keep_blank_values=False).get('id')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, KUGOU_MUSIC_HOSTS)): return song_infos
|
||||
assert 'special/single/' in urlparse(playlist_url).path, 'kugou playlist link must look like "https://www.kugou.com/yy/special/single/6914288.html"'
|
||||
headers = {'User-Agent': 'Android9-AndroidPhone-11239-18-0-playlist-wifi', 'Host': 'gatewayretry.kugou.com', 'x-router': 'pubsongscdn.kugou.com', 'mid': '239526275778893399526700786998289824956', 'dfid': '-', 'clienttime': str(time.time()).split('.')[0]}
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, playlist_result_first = [], 1, {}
|
||||
while True:
|
||||
api_url = f'http://gatewayretry.kugou.com/v2/get_other_list_file?specialid={playlist_id}&need_sort=1&module=CloudMusic&clientver=11239&pagesize=300&specalidpgc={playlist_id}&userid=0&page={page}&type=0&area_code=1&appid=1005'
|
||||
kugou_signature_func = lambda api_url: hashlib.md5(("OIlwieks28dk2k092lksi2UIkp" + "".join(sorted(str(api_url).split("?", 1)[1].split("&"))) + "OIlwieks28dk2k092lksi2UIkp").encode("utf-8")).hexdigest()
|
||||
try: (resp := self.get(api_url + '&signature=' + kugou_signature_func(api_url), headers=headers, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['data', 'info'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['data', 'info'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['data', 'count'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["hash"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
try: (resp := self.get(playlist_url, headers={'referer': 'https://www.kugou.com/songlist/'}, **request_overrides)).raise_for_status(); playlist_name = json_repair.loads(re.search(r'var\s+specialInfo\s*=\s*(\{.*?\});', resp.text, re.S).group(1))['name']
|
||||
except: playlist_name = None
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,230 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of KuwoMusicClient: http://www.kuwo.cn/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import time
|
||||
import random
|
||||
import base64
|
||||
import warnings
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import KUWO_MUSIC_HOSTS
|
||||
from ..utils.kuwoutils import KuwoMusicClientUtils
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, optionalimport, legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
def remove_prefix(value: str, prefix: str) -> str:
|
||||
if prefix and value.startswith(prefix):
|
||||
return value[len(prefix):]
|
||||
return value
|
||||
|
||||
|
||||
def remove_suffix(value: str, suffix: str) -> str:
|
||||
if suffix and value.endswith(suffix):
|
||||
return value[: -len(suffix)]
|
||||
return value
|
||||
|
||||
|
||||
'''KuwoMusicClient'''
|
||||
class KuwoMusicClient(BaseMusicClient):
|
||||
source = 'KuwoMusicClient'
|
||||
MUSIC_QUALITIES = [(22000, 'flac'), (320, 'mp3')] # playable flac and mp3 formats
|
||||
ENC_MUSIC_QUALITIES = [(4000, '4000kflac'), (2000, '2000kflac'), (320, '320kmp3'), (192, '192kmp3'), (128, '128kmp3')] # encrypted mgg format
|
||||
def __init__(self, **kwargs):
|
||||
super(KuwoMusicClient, 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/134.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/134.0.0.0 Safari/537.36'}
|
||||
self.default_parse_headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'}
|
||||
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 = {"vipver": "1", "client": "kt", "ft": "music", "cluster": "0", "strategy": "2012", "encoding": "utf8", "rformat": "json", "mobi": "1", "issubtitle": "1", "show_copyright_off": "1", "pn": "0", "rn": "10", "all": keyword}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'http://www.kuwo.cn/search/searchMusicBykeyWord?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['rn'] = page_size
|
||||
page_rule['pn'] = str(int(count // page_size))
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithcggapi'''
|
||||
def _parsewithcggapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
curl_cffi, request_overrides, song_id = optionalimport('curl_cffi'), request_overrides or {}, remove_prefix(str(search_result.get('MUSICRID') or search_result.get('musicrid')), 'MUSIC_')
|
||||
MUSIC_QUALITIES = ["acc", "wma", "ogg", "standard", "exhigh", "ape", "lossless", "hires", "zp", "hifi", "sur", "jymaster"][::-1][3:]
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(remove_suffix(str(meta.get('size', '0.00MB')), 'MB').strip()) if isinstance(meta, dict) else 0
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := curl_cffi.requests.get(f"https://kw-api.cenguigui.cn/?id={song_id}&type=song&level={quality}&format=json", timeout=10, impersonate="chrome131", verify=False, **request_overrides)).raise_for_status()
|
||||
except Exception: (resp := self.get(f"https://kw-api.cenguigui.cn/?id={song_id}&type=song&level={quality}&format=json", timeout=10, **request_overrides)).raise_for_status()
|
||||
if 'data' not in (download_result := resp2json(resp=resp)) or (safe_obtain_filesize_func(download_result['data']) < 0.01): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['data', '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(safeextractfromdict(download_result, ['data', 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['data', 'artist'], None)),
|
||||
album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=remove_suffix(str(safeextractfromdict(download_result, ['data', 'size'], "") or "0.00"), 'MB').strip() + ' MB',
|
||||
identifier=str(song_id), duration_s=safeextractfromdict(download_result, ['data', 'duration'], 0), duration=seconds2hms(safeextractfromdict(download_result, ['data', 'duration'], 0)), lyric=cleanlrc(safeextractfromdict(download_result, ['data', 'lyric'], 'NULL')) or 'NULL',
|
||||
cover_url=safeextractfromdict(download_result, ['data', 'pic'], ""), 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithyyy001api'''
|
||||
def _parsewithyyy001api(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
decrypt_func = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8')
|
||||
MUSIC_QUALITIES, REQUEST_KEYS = ["ff", "p", "h"], ['YzJmNjBlZDYtOTlmZC0xNjJlLWM0NzAtYjIxNDkwOGViNWI0YjYzYzFhN2E=', 'NTVjNTY3YzItNTJlNS1kMzdiLTE1N2MtMDE0MDIxNzEwYzc1NzY2OWNkYjc=', 'OTY4M2MwNzQtY2E3ZS01ZGYwLTUyZGEtMWEzNGZiNjVhOTZhZGU2NTczYjU=', 'OTdkZjQ0OTUtYzRjOS01MmFhLTNlODAtZjliZGFiODU1Y2UxZWIwN2JlZDk=']
|
||||
request_overrides, song_id = request_overrides or {}, remove_prefix(str(search_result.get('MUSICRID') or search_result.get('musicrid')), 'MUSIC_')
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
resp = next((resp for _ in range(5) if (resp := self.get(f"https://api.yyy001.com/api/kwmusic/?apikey={decrypt_func(random.choice(REQUEST_KEYS))}&action=music_url&music_id={song_id}&quality={quality}", timeout=10, **request_overrides)).json()['code'] in {'200', 200} or (time.sleep(1) or False)), None)
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
ext = download_url.split('?')[0].split('.')[-1]; duration_in_secs = search_result.get('DURATION') or search_result.get('duration')
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('SONGNAME') or search_result.get('name')), singers=legalizestring(search_result.get('ARTIST') or search_result.get('artist')), album=legalizestring(search_result.get('ALBUM') or search_result.get('album')),
|
||||
ext=ext, file_size_bytes=None, file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=search_result.get('hts_MVPIC') or search_result.get('albumpic'), 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
if self.default_cookies or request_overrides.get('cookies'): return SongInfo(source=self.source)
|
||||
for imp_func in [self._parsewithcggapi, self._parsewithyyy001api]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source)
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac, song_id = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source), remove_prefix(str(search_result.get('MUSICRID') or search_result.get('musicrid')), 'MUSIC_')
|
||||
if not isinstance(search_result, dict) or (not (search_result.get('MUSICRID') or search_result.get('musicrid'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for quality in KuwoMusicClient.MUSIC_QUALITIES:
|
||||
query = f"user=0&corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format={quality[1]}&rid={song_id}"
|
||||
try: (resp := self.get(f"http://mobi.kuwo.cn/mobi.s?f=kuwo&q={KuwoMusicClientUtils.encryptquery(query)}", headers={"user-agent": "okhttp/3.10.0"}, **request_overrides)).raise_for_status(); download_result = resp.text
|
||||
except Exception: continue
|
||||
if not (download_url := re.search(r'http[^\s$\"]+', download_result)): continue
|
||||
download_url = download_url.group(0); ext = download_url.split('?')[0].split('.')[-1]; duration_in_secs = search_result.get('DURATION') or search_result.get('duration')
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('SONGNAME') or search_result.get('name')), singers=legalizestring(search_result.get('ARTIST') or search_result.get('artist')), album=legalizestring(search_result.get('ALBUM') or search_result.get('album')),
|
||||
ext=ext, file_size_bytes=None, file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=search_result.get('hts_MVPIC') or search_result.get('albumpic'), 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']
|
||||
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_flac.with_valid_download_url and song_info_flac.largerthan(song_info): song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
encoded_params = KuwoMusicClientUtils.buildlyricsparams(song_id, True)
|
||||
try: (resp := self.get(f"http://newlyric.kuwo.cn/newlyric.lrc?{encoded_params}", **request_overrides)).raise_for_status(); lyric_result = {'content': resp.content}; lyric = cleanlrc(KuwoMusicClientUtils.convertrawlrc(KuwoMusicClientUtils.decodelyrics(resp.content, True))) or 'NULL'
|
||||
except Exception: lyric_result, lyric = {}, '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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['abslist']:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
request_overrides.setdefault('timeout', (10, 30))
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlparse(playlist_url).query, keep_blank_values=False).get('id')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = remove_suffix(remove_suffix(urlparse(playlist_url).path.strip('/').split('/')[-1], '.html'), '.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, KUWO_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, playlist_result_first = [], 1, {}
|
||||
while True:
|
||||
try: (resp := self.get(f"https://m.kuwo.cn/newh5app/wapi/api/www/playlist/playListInfo?pid={playlist_id}&pn={page}&rn=100", **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['data', 'musicList'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['data', 'musicList'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['data', 'total'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["musicrid"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['data', 'name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,168 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of MiguMusicClient: https://music.migu.cn/v5/#/musicLibrary
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import requests
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import MIGU_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse, parse_qs, urlsplit, urljoin
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, byte2mb, resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
'''MiguMusicClient'''
|
||||
class MiguMusicClient(BaseMusicClient):
|
||||
source = 'MiguMusicClient'
|
||||
MUSIC_QUALITIES = {'LQ': 'mp3', 'PQ': 'mp3', 'HQ': 'mp3', 'SQ': 'flac', 'ZQ': 'flac', 'Z3D': 'flac', 'ZQ24': 'flac', 'ZQ32': 'flac'}
|
||||
def __init__(self, **kwargs):
|
||||
super(MiguMusicClient, self).__init__(**kwargs)
|
||||
self.default_search_headers = {
|
||||
"accept": "application/json, text/plain, */*", "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "activityid": "v4_zt_2022_music", "appid": "ce", "channel": "014X031", "connection": "keep-alive", "deviceid": "E60C6B2F-7F11-4362-9FCE-6F1CC86E0F18",
|
||||
"host": "c.musicapp.migu.cn", "hwid": "", "imei": "", "h5page": "", "imsi": "", "location-info": "", "mgm-user-agent": "", "oaid": "", "uid": "", "location-data": "", "logid": "h5page[1808]", "mgm-network-operators": "02", "mgm-network-standard": "03", "mgm-network-type": "03", "recommendstatus": "1",
|
||||
"referer": "https://y.migu.cn/app/v4/zt/2022/music/index.html", "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", "origin": "https://y.migu.cn", "sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site", "subchannel": "014X031", "test": "00", "ua": "Android_migu", "version": "6.8.8", "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_parse_headers = {
|
||||
"accept": "application/json, text/plain, */*", "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "activityid": "v4_zt_2022_music", "appid": "ce", "channel": "014X031", "connection": "keep-alive", "deviceid": "E60C6B2F-7F11-4362-9FCE-6F1CC86E0F18",
|
||||
"host": "c.musicapp.migu.cn", "hwid": "", "imei": "", "h5page": "", "imsi": "", "location-info": "", "mgm-user-agent": "", "oaid": "", "uid": "", "location-data": "", "logid": "h5page[1808]", "mgm-network-operators": "02", "mgm-network-standard": "03", "mgm-network-type": "03", "recommendstatus": "1",
|
||||
"referer": "https://y.migu.cn/app/v4/zt/2022/music/index.html", "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", "origin": "https://y.migu.cn", "sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site", "subchannel": "014X031", "test": "00", "ua": "Android_migu", "version": "6.8.8", "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_download_headers = {
|
||||
"accept": "*/*", "accept-encoding": "identity;q=1, *;q=0", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "connection": "keep-alive", "host": "freetyst.nf.migu.cn", "range": "bytes=0-", "sec-fetch-mode": "no-cors", "sec-fetch-dest": "audio",
|
||||
"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-site": "same-site", "referer": "https://y.migu.cn/app/v4/zt/2022/music/index.html",
|
||||
"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 {}
|
||||
# search rules
|
||||
default_rule = {"text": keyword, 'pageNo': 1, 'pageSize': 20, 'isCopyright': 1, 'sort': 1, 'searchSwitch': {"song": 1, "album": 0, "singer": 0, "tagSong": 1, "mvSong": 0, "bestShow": 1}}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://c.musicapp.migu.cn/v1.0/content/search_all.do?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['pageSize'] = page_size
|
||||
page_rule['pageNo'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (content_id := search_result.get('contentId'))) or (not (copyright_id := search_result.get('contentId'))): return song_info
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(str(meta.get('size') or meta.get('iosSize') or meta.get('androidSize') or meta.get('isize') or meta.get('asize') or '0').removesuffix('MB').strip()) if isinstance(meta, dict) else 0
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for rate in sorted((search_result.get('rateFormats', []) or []) + (search_result.get('newRateFormats', []) or []) + (search_result.get('audioFormats', []) or []), key=lambda x: int(safe_obtain_filesize_func(x)), reverse=True):
|
||||
if (not isinstance(rate, dict)) or (byte2mb(safe_obtain_filesize_func(rate)) == 'NULL') or (not rate.get('formatType')) or (not rate.get('resourceType')): continue
|
||||
if rate['formatType'] in {'Z3D'}: continue # TODO: support decrypt Z3D files in migu music
|
||||
try: (resp := self.get(f"https://c.musicapp.migu.cn/MIGUM3.0/strategy/listen-url/v2.4?resourceType={rate['resourceType']}&netType=01&scene=&toneFlag={rate['formatType']}&contentId={content_id}©rightId={copyright_id}&lowerQualityContentId={content_id}", **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'url'], "") or f"https://app.pd.nf.migu.cn/MIGUM3.0/v1.0/content/sub/listenSong.do?channel=mx©rightId={copyright_id}&contentId={content_id}&toneFlag={rate['formatType']}&resourceType={rate['resourceType']}&userId=15548614588710179085069&netType=00"
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
download_url = re.sub(r'(?<=/)MP3_128_16_Stero(?=/)', 'MP3_320_16_Stero', download_url)
|
||||
duration_in_secs = safeextractfromdict(download_result, ['data', 'song', 'duration'], 0)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('name') or search_result.get('songName')), singers=legalizestring(', '.join([singer.get('name') for singer in (search_result.get('singers') or search_result.get('singerList') or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(search_result.get('album') or (', '.join([album.get('name') for album in (search_result.get('albums') or []) if isinstance(album, dict) and album.get('name')]))), ext=MiguMusicClient.MUSIC_QUALITIES.get(rate['formatType']) or 'mp3', file_size_bytes=safe_obtain_filesize_func(rate), file_size=byte2mb(safe_obtain_filesize_func(rate)), identifier=content_id,
|
||||
duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=safeextractfromdict(search_result, ['imgItems', -1, 'img'], None) or next((search_result.get(k) for k in ("img3", "img2", "img1") if search_result.get(k)), 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.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.cover_url and not song_info.cover_url.startswith('http'): song_info.cover_url = urljoin('https://d.musicapp.migu.cn', song_info.cover_url)
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
lyric_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", "Referer": "https://y.migu.cn/"}
|
||||
try: lyric_url = safeextractfromdict(search_result, ['lyricUrl'], '') or self.get(f"https://app.c.nf.migu.cn/MIGUM3.0/strategy/pc/listen/v1.0?scene=&netType=01&resourceType=2©rightId={copyright_id}&contentId={content_id}&toneFlag=PQ", **request_overrides).json()['data']['lrcUrl']; (resp := requests.get(lyric_url, headers=lyric_headers, allow_redirects=True, **request_overrides)).raise_for_status(); resp.encoding = 'utf-8'; lyric, lyric_result = cleanlrc(resp.text), {'lyric': resp.text}
|
||||
except Exception: lyric_result, lyric = {}, '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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['songResultData']['result']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlsplit(urlsplit(playlist_url).fragment).query).get('playlistId')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, MIGU_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, playlist_result_first = [], 1, {}
|
||||
while True:
|
||||
try: (resp := self.get(f"https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0?pageNo={page}&pageSize=50&playlistId={playlist_id}", **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['data', 'songList'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['data', 'songList'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['data', 'totalCount'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["contentId"]: d for d in tracks_in_playlist}.values())
|
||||
try: (resp := self.get(f'https://app.c.nf.migu.cn/resource/playlist/v2.0?playlistId={playlist_id}', **request_overrides)).raise_for_status(); playlist_result_first['meta_info'] = resp2json(resp=resp)
|
||||
except Exception: pass
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['meta_info', 'data', 'title'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,449 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of NeteaseMusicClient: https://music.163.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import copy
|
||||
import time
|
||||
import base64
|
||||
import random
|
||||
import hashlib
|
||||
import warnings
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from ..utils.hosts import NETEASE_MUSIC_HOSTS, hostmatchessuffix, obtainhostname
|
||||
from ..utils.neteaseutils import EapiCryptoUtils, MUSIC_QUALITIES, DEFAULT_COOKIES
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, extractdurationsecondsfromlrc, touchdir, byte2mb, useparseheaderscookies, cleanlrc, SongInfo, AudioLinkTester
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
def remove_suffix(value: str, suffix: str) -> str:
|
||||
if suffix and value.endswith(suffix):
|
||||
return value[: -len(suffix)]
|
||||
return value
|
||||
|
||||
|
||||
'''NeteaseMusicClient'''
|
||||
class NeteaseMusicClient(BaseMusicClient):
|
||||
source = 'NeteaseMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(NeteaseMusicClient, 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/134.0.0.0 Safari/537.36', 'Referer': 'https://music.163.com/'}
|
||||
self.default_parse_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', 'Referer': 'https://music.163.com/'}
|
||||
self.default_download_headers = {}
|
||||
self.default_headers = self.default_search_headers
|
||||
self.default_search_cookies = self.default_search_cookies or DEFAULT_COOKIES
|
||||
self.default_parse_cookies = self.default_parse_cookies or DEFAULT_COOKIES
|
||||
self.default_download_cookies = self.default_download_cookies or DEFAULT_COOKIES
|
||||
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 = {'s': keyword, 'type': 1, 'limit': 10, 'offset': 0}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://music.163.com/api/cloudsearch/pc'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = int(count // page_size) * page_size
|
||||
search_urls.append({'url': base_url, 'data': page_rule})
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithxiaoqinapi'''
|
||||
def _parsewithxiaoqinapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
to_seconds_func = lambda x: (lambda s: 0 if not s else (lambda p: p[-3]*3600+p[-2]*60+p[-1] if len(p)>=3 else p[0]*60+p[1] if len(p)==2 else p[0] if len(p)==1 else 0)([int(v) for v in re.findall(r'\d+', s.replace(':', ':'))]) if (':' in s or ':' in s) else (lambda h,m,sec,num: (lambda tot: tot if tot>0 else num)(h*3600+m*60+sec))(int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:小时|时|h|hr)', s)) else 0, int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:分钟|分|m|min)', s)) else 0, (int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:秒|s|sec)', s)) else (int(mo.group(1)) if (mo:=re.search(r'(?:分钟|分|m|min)\s*(\d+)\b', s)) else 0)), int(mo.group(0)) if (mo:=re.search(r'\d+', s)) else 0))(str(x).strip().lower())
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.post('https://wyapi-eo.toubiec.cn/api/getSongUrl', json={'id': song_id, 'level': quality}, timeout=10, verify=False, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.post('https://wyapi-eo.toubiec.cn/api/getSongInfo', json={'id': song_id}, timeout=10, verify=False, **request_overrides)).raise_for_status(); download_result['song_info'] = resp2json(resp=resp)
|
||||
except Exception: pass
|
||||
try: (resp := self.post('https://wyapi-eo.toubiec.cn/api/getSongLyric', json={'id': song_id}, timeout=10, verify=False, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp)
|
||||
except Exception: lyric_result = {}
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': lyric_result, 'quality': quality}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['song_info', 'data', 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['song_info', 'data', 'singer'], None)), album=legalizestring(safeextractfromdict(download_result, ['song_info', 'data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None,
|
||||
identifier=song_id, duration_s=to_seconds_func(safeextractfromdict(download_result, ['data', 'duration'], "")), duration=seconds2hms(to_seconds_func(safeextractfromdict(download_result, ['data', 'duration'], ""))), lyric=cleanlrc(safeextractfromdict(lyric_result, ['data', 'lrc'], "")) or "NULL", cover_url=safeextractfromdict(download_result, ['song_info', 'data', 'picimg'], 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithcggapi'''
|
||||
def _parsewithcggapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(remove_suffix(str(meta.get('size', '0.00MB')), 'MB').strip()) if isinstance(meta, dict) else 0
|
||||
to_seconds_func = lambda x: (lambda s: 0 if not s else (lambda p: p[-3]*3600+p[-2]*60+p[-1] if len(p)>=3 else p[0]*60+p[1] if len(p)==2 else p[0] if len(p)==1 else 0)([int(v) for v in re.findall(r'\d+', s.replace(':', ':'))]) if (':' in s or ':' in s) else (lambda h,m,sec,num: (lambda tot: tot if tot>0 else num)(h*3600+m*60+sec))(int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:小时|时|h|hr)', s)) else 0, int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:分钟|分|m|min)', s)) else 0, (int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:秒|s|sec)', s)) else (int(mo.group(1)) if (mo:=re.search(r'(?:分钟|分|m|min)\s*(\d+)\b', s)) else 0)), int(mo.group(0)) if (mo:=re.search(r'\d+', s)) else 0))(str(x).strip().lower())
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(url=f'https://api-v2.cenguigui.cn/api/netease/music_v1.php?id={song_id}&type=json&level={quality}', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if '获取歌曲地址失败,可能是会员到期了' in resp2json(resp=resp)['data']['url']: break
|
||||
if 'data' not in (download_result := resp2json(resp=resp)) or (safe_obtain_filesize_func(download_result['data']) < 0.01): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['data', 'url'], '')) or not str(download_url).startswith('http'): continue
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'name'], None)), singers=legalizestring(safeextractfromdict(download_result, ['data', 'artist'], None)), album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=remove_suffix(str(safeextractfromdict(download_result, ['data', 'size'], '')), 'MB').strip() + ' MB',
|
||||
identifier=song_id, duration_s=to_seconds_func(safeextractfromdict(download_result, ['data', 'duration'], '')), duration=seconds2hms(to_seconds_func(safeextractfromdict(download_result, ['data', 'duration'], ''))), lyric=cleanlrc(safeextractfromdict(download_result, ['data', 'lyric'], 'NULL')), cover_url=safeextractfromdict(download_result, ['data', 'pic'], ""), 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithtmetuapi'''
|
||||
def _parsewithtmetuapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(url=f'https://www.tmetu.cn/api/music/api.php?miss=songAll&id={song_id}&level={quality}&withLyric=true', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'audioUrl'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['data', 'duration'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'name'], None)), singers=legalizestring(str(safeextractfromdict(download_result, ['data', 'artists'], '') or '').replace('/', ', ')), album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=safeextractfromdict(download_result, ['data', 'size'], None),
|
||||
file_size=byte2mb(safeextractfromdict(download_result, ['data', 'size'], 0)), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=cleanlrc(safeextractfromdict(download_result, ['data', 'lyric'], 'NULL')) or 'NULL', cover_url=safeextractfromdict(download_result, ['data', 'picUrl'], 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithtmetuapi'''
|
||||
def _parsewithtmetuapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
signature = hashlib.md5(((timestamp_str := str(int(time.time()))) + 'kxz_163music_secret_key_2024').encode('utf-8')).hexdigest()
|
||||
params = {"action": "music", "url": str(song_id), "level": quality, "type": "json", "timestamp": timestamp_str, "signature": signature}
|
||||
try: (resp := self.get(url=f'https://music.rrvenn.cn/api/api.php', params=params, timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['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': {}, 'quality': quality}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(str(download_result.get('ar_name')).replace('/', ', ') if download_result.get('ar_name') else download_result.get('ar_name')), album=legalizestring(download_result.get('al_name')),
|
||||
ext=str(download_url).split('?')[0].split('.')[-1], file_size=remove_suffix(str(download_result.get('size')), 'MB').strip() + ' MB', identifier=str(song_id), duration_s=extractdurationsecondsfromlrc(download_result.get('lyric')), duration=seconds2hms(extractdurationsecondsfromlrc(download_result.get('lyric'))), lyric=cleanlrc(download_result.get('lyric') or 'NULL') or 'NULL',
|
||||
cover_url=download_result.get('pic'), 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithxuanluogeapi'''
|
||||
def _parsewithxuanluogeapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(url=f'https://music.xuanluoge.top/api.php?miss=getMusicUrl&id={song_id}&level={quality}', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 0, 'url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.get(url=f'https://music.xuanluoge.top/api.php?miss=songDetail&id={song_id}', timeout=10, **request_overrides)).raise_for_status(); download_result['songDetail'] = resp2json(resp=resp)
|
||||
except Exception: pass
|
||||
try: (resp := self.get(url=f'https://music.xuanluoge.top/api.php?miss=lyric&id={song_id}', timeout=10, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp)
|
||||
except Exception: lyric_result = dict()
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['songDetail', 'data', 'dt'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': lyric_result, 'quality': quality}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['songDetail', 'data', 'name'], None)), singers=legalizestring(', '.join([singer.get('name') for singer in (safeextractfromdict(download_result, ['songDetail', 'data', 'ar'], []) or []) if isinstance(singer, dict) and singer.get('name')])), album=legalizestring(safeextractfromdict(download_result, ['songDetail', 'data', 'al', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size_bytes=safeextractfromdict(download_result, ['data', 0, 'size'], None), file_size=byte2mb(safeextractfromdict(download_result, ['data', 0, 'size'], 0)), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=cleanlrc(safeextractfromdict(lyric_result, ['data', 'lrc'], 'NULL')) or 'NULL', cover_url=safeextractfromdict(download_result, ['songDetail', 'data', 'al', 'picUrl'], 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithbugpkapi'''
|
||||
def _parsewithbugpkapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(remove_suffix(str(meta.get('size', '0.00MB')), 'MB').strip()) if isinstance(meta, dict) else 0
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(f'https://api.bugpk.com/api/163_music?ids={song_id}&level={quality}&type=json', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if 'url' not in (download_result := resp2json(resp=resp)) or (safe_obtain_filesize_func(download_result) < 0.01): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['url'], '')) or not str(download_url).startswith('http'): continue
|
||||
lyric, download_url_status = cleanlrc(safeextractfromdict(download_result, ['lyric'], 'NULL')) or 'NULL', self.audio_link_tester.test(download_url, request_overrides)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(str(download_result.get('ar_name')).replace('/', ', ') if download_result.get('ar_name') else download_result.get('ar_name')), album=legalizestring(download_result.get('al_name')), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size_bytes=None, file_size=remove_suffix(str(safeextractfromdict(download_result, ['size'], '')), 'MB').strip() + ' MB', identifier=song_id, duration_s=extractdurationsecondsfromlrc(lyric), duration=seconds2hms(extractdurationsecondsfromlrc(lyric)), lyric=lyric, cover_url=download_result.get('pic'), download_url=download_url, download_url_status=download_url_status,
|
||||
)
|
||||
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.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithyutangxiaowuapi'''
|
||||
def _parsewithyutangxiaowuapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(remove_suffix(str(meta.get('size', '0.00MB')), 'MB').strip()) if isinstance(meta, dict) else 0
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(f'https://yutangxiaowu.cn:4000/Song_V1?url={song_id}&level={quality}&type=json', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if 'url' not in (download_result := resp2json(resp=resp)) or (safe_obtain_filesize_func(download_result) < 0.01): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['url'], '')) or not str(download_url).startswith('http'): continue
|
||||
lyric, download_url_status = cleanlrc(safeextractfromdict(download_result, ['lyric'], 'NULL')) or 'NULL', self.audio_link_tester.test(download_url, request_overrides)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(str(download_result.get('ar_name')).replace('/', ', ') if download_result.get('ar_name') else download_result.get('ar_name')), album=legalizestring(download_result.get('al_name')), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size_bytes=None, file_size=remove_suffix(str(safeextractfromdict(download_result, ['size'], "")), 'MB').strip() + ' MB', identifier=song_id, duration_s=extractdurationsecondsfromlrc(lyric), duration=seconds2hms(extractdurationsecondsfromlrc(lyric)), lyric=lyric, cover_url=download_result.get('pic'), download_url=download_url, download_url_status=download_url_status,
|
||||
)
|
||||
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.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithnycnmbyfunsapi'''
|
||||
def _parsewithnycnmbyfunsapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
decrypt_func, REQUEST_KEYS = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8'), ['OTJiMWE4ZWQyMjg5ZmI4ZTk4NTAxZWMyYzE2Yzk4MWRmMWI1NzliMjhhM2Y2ZjIyMDFiYmJlNDc2YmI3Njc0MA==']
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES[4:]:
|
||||
try: (resp := self.get(f'https://api.nycnm.cn/API/163music.php?ids={song_id}&level={quality}&type=json&apikey={decrypt_func(random.choice(REQUEST_KEYS))}', timeout=10, **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
|
||||
except Exception: break
|
||||
try: download_url = self.get(f'https://api.byfuns.top/1/?id={song_id}&level={quality}', timeout=10, **request_overrides).text.strip()
|
||||
except Exception: break
|
||||
if not str(download_url).startswith('http'): continue
|
||||
lyric, download_url_status = cleanlrc(safeextractfromdict(download_result, ['lyric'], 'NULL')) or 'NULL', self.audio_link_tester.test(download_url, request_overrides)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(str(download_result.get('ar_name')).replace('/', ', ') if download_result.get('ar_name') else download_result.get('ar_name')), album=legalizestring(download_result.get('al_name')),
|
||||
ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration_s=extractdurationsecondsfromlrc(lyric), duration=seconds2hms(extractdurationsecondsfromlrc(lyric)), lyric=lyric, cover_url=download_result.get('pic'), download_url=download_url, download_url_status=download_url_status,
|
||||
)
|
||||
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.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithcunyuapi'''
|
||||
def _parsewithcunyuapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(url=f'https://www.cunyuapi.top/163music_play?id={song_id}&quality={quality}', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['song_file_url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
duration_in_secs = extractdurationsecondsfromlrc(str(download_result.get('lyric', 'NULL') or 'NULL'))
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(str(safeextractfromdict(download_result, ['ar_name'], '') or '').replace('/', ', ')), album=legalizestring(download_result.get('al_name', None)), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size=remove_suffix(str(download_result.get('size') or ''), 'MB').strip() + ' MB', identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=cleanlrc(download_result.get('lyric', 'NULL')) or 'NULL', cover_url=download_result.get('img'), 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']
|
||||
if song_info.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithcyruiapi'''
|
||||
def _parsewithcyruiapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result['id']
|
||||
try: (resp := self.get(f'https://blog.cyrui.cn/netease/api/getSongDetail.php?id={song_id}', **request_overrides)).raise_for_status(); download_result = resp2json(resp=resp)
|
||||
except Exception: download_result = dict()
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.get(url=f'https://blog.cyrui.cn/netease/api/getMusicUrl.php?id={song_id}&level={quality}', timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_result['getMusicUrl'] = resp2json(resp=resp)
|
||||
if not (download_url := safeextractfromdict(download_result, ['getMusicUrl', 'data', 0, 'url'], '')) or not download_url.startswith('http'): continue
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['songs', 0, 'dt'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['songs', 0, 'name'], None)), singers=legalizestring(', '.join([singer.get('name') for singer in (safeextractfromdict(download_result, ['songs', 0, 'ar'], []) or []) if isinstance(singer, dict) and singer.get('name')])), album=legalizestring(safeextractfromdict(download_result, ['songs', 0, 'al', 'name'], None)), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size_bytes=safeextractfromdict(download_result, ['getMusicUrl', 'data', 0, 'size'], 0), file_size=byte2mb(safeextractfromdict(download_result, ['getMusicUrl', 'data', 0, 'size'], 0)), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=safeextractfromdict(download_result, ['songs', 0, 'al', 'picUrl'], 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']
|
||||
if song_info.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithxianyuwapi'''
|
||||
def _parsewithxianyuwapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
decrypt_func, REQUEST_KEYS = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8'), ['c2stOTUwZTc4MTNjMzhjMmUzMWQzOWQ4NzlkMzIwNDg4OTU=', 'c2stNjJjZGIwM2UyMjcwZWIzOTY4Y2NhNzg4MTM5OWY0MTI=']
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result['id'], SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]})
|
||||
# parse
|
||||
(resp := self.get(f'https://apii.xianyuw.cn/api/v1/163-music-search?id={song_id}&key={decrypt_func(random.choice(REQUEST_KEYS))}&no_url=0&br=hires', **request_overrides)).raise_for_status()
|
||||
download_url: str = (download_result := resp2json(resp=resp))['data']['url']
|
||||
if not download_url or not str(download_url).startswith('http'): return song_info
|
||||
lyric = cleanlrc(safeextractfromdict(download_result, ['data', 'lrc'], 'NULL')) or 'NULL'
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': 'hires'}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'title'], None)), singers=legalizestring(str(safeextractfromdict(download_result, ['data', 'author'], '')).replace('/', ', ')), album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)),
|
||||
ext=download_url.split('?')[0].split('.')[-1], file_size=None, identifier=song_id, duration_s=extractdurationsecondsfromlrc(lyric), duration=seconds2hms(extractdurationsecondsfromlrc(lyric)), lyric=lyric, cover_url=safeextractfromdict(download_result, ['data', 'cover'], 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']
|
||||
if song_info.album == 'NULL': song_info.album = legalizestring(safeextractfromdict(search_result, ['al', 'name'], None))
|
||||
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
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
cookies = self.default_cookies or request_overrides.get('cookies')
|
||||
if cookies and (cookies != DEFAULT_COOKIES): return SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]})
|
||||
for imp_func in [self._parsewithcggapi, self._parsewithxuanluogeapi, self._parsewithtmetuapi, self._parsewithbugpkapi, self._parsewithcyruiapi, self._parsewithcunyuapi, self._parsewithyutangxiaowuapi, self._parsewithnycnmbyfunsapi, self._parsewithxianyuwapi, self._parsewithxiaoqinapi, self._parsewithtmetuapi]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]})
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]}), request_overrides or {}, song_info_flac or SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]})
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
if not search_result.get('name', None):
|
||||
try: (resp := self.post("https://interface3.music.163.com/api/v3/song/detail", data={'c': json.dumps([{"id": song_id, "v": 0}])}, **request_overrides)).raise_for_status(); search_result.update(resp2json(resp=resp)['songs'][0])
|
||||
except Exception: pass
|
||||
for quality_idx, quality in enumerate(MUSIC_QUALITIES):
|
||||
if song_info_flac.with_valid_download_url and quality_idx >= MUSIC_QUALITIES.index(song_info_flac.raw_data.get('quality', MUSIC_QUALITIES[-1])): song_info = song_info_flac; break
|
||||
params = {'ids': [song_id], 'level': quality, 'encodeType': 'flac', 'header': json.dumps({"os": "pc", "appver": "", "osver": "", "deviceId": "pyncm!", "requestId": str(random.randrange(20000000, 30000000))})}
|
||||
if quality == 'sky': params['immerseType'] = 'c51'
|
||||
params = EapiCryptoUtils.encryptparams(url='https://interface3.music.163.com/eapi/song/enhance/player/url/v1', payload=params)
|
||||
(cookies := {"os": "pc", "appver": "", "osver": "", "deviceId": "pyncm!"}).update(copy.deepcopy(self.default_cookies))
|
||||
try: (resp := self.post('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', data={"params": params}, cookies=cookies, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
if ('data' not in (download_result := resp2json(resp))) or (not download_result['data']): continue
|
||||
if not (download_url := safeextractfromdict(download_result, ['data', 0, 'url'], '')) or not str(download_url).startswith('http'): continue
|
||||
try: duration_in_secs = float(search_result.get('dt', 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.get('name')), singers=legalizestring(', '.join([singer.get('name') for singer in (safeextractfromdict(search_result, ['ar'], []) or []) if isinstance(singer, dict) and singer.get('name')])), album=legalizestring(safeextractfromdict(search_result, ['al', '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='NULL', cover_url=safeextractfromdict(search_result, ['al', 'picUrl'], 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']
|
||||
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_flac.with_valid_download_url and song_info_flac.largerthan(song_info): song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
data = {'id': song_id, 'cp': 'false', 'tv': '0', 'lv': '0', 'rv': '0', 'kv': '0', 'yv': '0', 'ytv': '0', 'yrv': '0'}
|
||||
try: (resp := self.post('https://interface3.music.163.com/api/song/lyric', data=data, **request_overrides)).raise_for_status(); lyric = cleanlrc(safeextractfromdict((lyric_result := resp2json(resp)), ['lrc', 'lyric'], 'NULL')) or 'NULL'
|
||||
except Exception: lyric_result, lyric = {}, '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
|
||||
if not song_info.duration or song_info.duration == '-:-:-': song_info.duration = seconds2hms(extractdurationsecondsfromlrc(song_info.lyric))
|
||||
# return
|
||||
return song_info
|
||||
'''_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_meta = copy.deepcopy(search_url); search_url = search_meta.pop('url')
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.post(search_url, **search_meta, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp)['result']['songs']:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if (cookies := self.default_cookies or request_overrides.get('cookies')) and (cookies != DEFAULT_COOKIES) else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source, raw_data={'quality': MUSIC_QUALITIES[-1]})
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
request_overrides.setdefault('timeout', (10, 30))
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlparse(urlparse(playlist_url).fragment).query, keep_blank_values=True).get('id')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = remove_suffix(remove_suffix(urlparse(playlist_url).path.strip('/').split('/')[-1], '.html'), '.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, NETEASE_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
(resp := self.post('https://music.163.com/api/v6/playlist/detail', data={'id': playlist_id}, **request_overrides)).raise_for_status()
|
||||
tracks_in_playlist = (safeextractfromdict((playlist_result := resp2json(resp=resp)), ['playlist', 'trackIds'], []) or [])
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if (cookies := self.default_cookies or request_overrides.get('cookies')) and (cookies != DEFAULT_COOKIES) else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['playlist', 'name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,172 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of QianqianMusicClient: http://music.taihe.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import copy
|
||||
import hashlib
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from pathvalidate import sanitize_filepath
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from ..utils.hosts import QIANQIAN_MUSIC_HOSTS
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, byte2mb, resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, cookies2string, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
'''QianqianMusicClient'''
|
||||
class QianqianMusicClient(BaseMusicClient):
|
||||
source = 'QianqianMusicClient'
|
||||
APPID = '16073360'
|
||||
MUSIC_QUALITIES = ['3000', '320', '128', '64']
|
||||
def __init__(self, **kwargs):
|
||||
super(QianqianMusicClient, self).__init__(**kwargs)
|
||||
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", "referer": "https://music.91q.com/player", "sec-ch-ua-platform": "\"Windows\"",
|
||||
"sec-ch-ua": "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", "sec-fetch-site": "same-origin", "sec-fetch-dest": "empty", "sec-ch-ua-mobile": "?0", "priority": "u=1, i",
|
||||
"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", "from": "web", "sec-fetch-mode": "cors",
|
||||
}
|
||||
if self.default_search_cookies: self.default_search_headers['authorization'] = f"access_token {self.default_search_cookies.get('access_token', '')}"
|
||||
if self.default_search_cookies: self.default_search_headers['cookie'] = cookies2string(self.default_search_cookies)
|
||||
self.default_parse_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", "referer": "https://music.91q.com/player", "sec-ch-ua-platform": "\"Windows\"",
|
||||
"sec-ch-ua": "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", "sec-fetch-site": "same-origin", "sec-fetch-dest": "empty", "sec-ch-ua-mobile": "?0", "priority": "u=1, i",
|
||||
"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", "from": "web", "sec-fetch-mode": "cors",
|
||||
}
|
||||
if self.default_parse_cookies: self.default_parse_headers['authorization'] = f"access_token {self.default_parse_cookies.get('access_token', '')}"
|
||||
if self.default_parse_cookies: self.default_parse_headers['cookie'] = cookies2string(self.default_parse_cookies)
|
||||
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"}
|
||||
if self.default_download_cookies: self.default_download_headers['authorization'] = f"access_token {self.default_download_cookies.get('access_token', '')}"
|
||||
if self.default_download_cookies: self.default_download_headers['cookie'] = cookies2string(self.default_download_cookies)
|
||||
self.default_headers = self.default_search_headers
|
||||
self._initsession()
|
||||
'''_addsignandtstoparams'''
|
||||
def _addsignandtstoparams(self, params: dict):
|
||||
secret = '0b50b02fd0d73a9c4c8c3a781c30845f'
|
||||
params['timestamp'] = str(int(time.time()))
|
||||
keys = sorted(params.keys()); string = "&".join(f"{k}={params[k]}" for k in keys)
|
||||
params['sign'] = hashlib.md5((string + secret).encode('utf-8')).hexdigest()
|
||||
return params
|
||||
'''_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 = {'word': keyword, 'type': '1', 'pageNo': '1', 'pageSize': '10', 'appid': QianqianMusicClient.APPID}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://music.91q.com/v1/search?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['pageSize'] = page_size
|
||||
page_rule['pageNo'] = str(int(count // page_size) + 1)
|
||||
page_rule = self._addsignandtstoparams(params=page_rule)
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('TSID'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for rate in QianqianMusicClient.MUSIC_QUALITIES:
|
||||
params = self._addsignandtstoparams(params={'TSID': song_id, 'appid': QianqianMusicClient.APPID, 'rate': rate})
|
||||
try: (resp := self.get("https://music.91q.com/v1/song/tracklink", params=params, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp)), ['data', 'path'], '') or safeextractfromdict(download_result, ['data', 'trail_audio_info', 'path'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
file_size_bytes, duration_in_secs = safeextractfromdict(download_result, ['data', 'size'], 0), safeextractfromdict(download_result, ['data', 'duration'], 0)
|
||||
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('name') for singer in (search_result.get('artist', []) or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(search_result.get('albumTitle', None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=file_size_bytes, file_size=byte2mb(file_size_bytes), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=search_result.get('pic'),
|
||||
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']
|
||||
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: return song_info
|
||||
# supplement lyric results
|
||||
try: (resp := self.get(search_result['lyric'], **request_overrides)).raise_for_status(); resp.encoding = 'utf-8'; lyric, lyric_result = cleanlrc(resp.text) or 'NULL', dict(lyric=resp.text)
|
||||
except Exception: lyric_result, lyric = dict(), 'NULL'
|
||||
if (song_info.singers == 'NULL') and lyric and (song_info.lyric not in {'NULL'}): song_info.singers = (m.group(1) if (m := re.search(r'\[ar:(.*?)\]', lyric)) else '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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['data']['typeTrack']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, QIANQIAN_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, playlist_result_first = [], 1, None
|
||||
while True:
|
||||
params = {'pageNo': page, 'pageSize': 50, 'appid': QianqianMusicClient.APPID, 'id': playlist_id}
|
||||
try: (resp := self.get(f"https://music.91q.com/v1/tracklist/info", params=self._addsignandtstoparams(params=params), **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['data', 'trackList'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['data', 'trackList'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['data', 'trackCount'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["TSID"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['data', 'title'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,266 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of QobuzMusicClient: https://play.qobuz.com/discover
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import copy
|
||||
import base64
|
||||
import hashlib
|
||||
import requests
|
||||
from itertools import product
|
||||
from .base import BaseMusicClient
|
||||
from collections import OrderedDict
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import QOBUZ_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse, urljoin, parse_qs
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, hostmatchessuffix, obtainhostname, useparseheaderscookies, SongInfo, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''QobuzMusicClient'''
|
||||
class QobuzMusicClient(BaseMusicClient):
|
||||
source = 'QobuzMusicClient'
|
||||
APP_ID = None
|
||||
SECRETS = None
|
||||
MUSIC_QUALITIES = (27, 7, 6, 5)
|
||||
get_token_func = lambda cookies, *keys: next((cookies.get(k) for k in keys if cookies.get(k)), None)
|
||||
def __init__(self, **kwargs):
|
||||
super(QobuzMusicClient, self).__init__(**kwargs)
|
||||
if self.default_search_cookies: assert QobuzMusicClient.get_token_func(self.default_search_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token"), '"x-user-auth-token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#qobuz-music-download'
|
||||
if self.default_parse_cookies: assert QobuzMusicClient.get_token_func(self.default_parse_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token"), '"x-user-auth-token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#qobuz-music-download'
|
||||
if self.default_download_cookies: assert QobuzMusicClient.get_token_func(self.default_download_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token"), '"x-user-auth-token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#qobuz-music-download'
|
||||
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", "origin": "https://play.qobuz.com", "priority": "u=1, i", "referer": "https://play.qobuz.com/", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
|
||||
"sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "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_parse_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", "origin": "https://play.qobuz.com", "priority": "u=1, i", "referer": "https://play.qobuz.com/", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
|
||||
"sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "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/143.0.0.0 Safari/537.36"}
|
||||
if self.default_search_cookies: self.default_search_headers.update({'X-User-Auth-Token': QobuzMusicClient.get_token_func(self.default_search_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token")})
|
||||
if self.default_parse_cookies: self.default_parse_headers.update({'X-User-Auth-Token': QobuzMusicClient.get_token_func(self.default_parse_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token")})
|
||||
if self.default_download_cookies: self.default_download_headers.update({'X-User-Auth-Token': QobuzMusicClient.get_token_func(self.default_download_cookies, "user_auth_token", "X-User-Auth-Token", "x-user-auth-token")})
|
||||
self.default_headers = self.default_search_headers; self.default_search_cookies = {}; self.default_parse_cookies = {}; self.default_download_cookies = {}
|
||||
self._initsession()
|
||||
'''_setappidandsecrets'''
|
||||
def _setappidandsecrets(self, request_overrides: dict = None) -> tuple[str, list[str]]:
|
||||
if (QobuzMusicClient.APP_ID is not None) and (QobuzMusicClient.SECRETS is not None): self.default_headers.update({"X-App-Id": QobuzMusicClient.APP_ID}); return
|
||||
request_overrides = request_overrides or {}
|
||||
(resp := self.get("https://play.qobuz.com/login", **request_overrides)).raise_for_status()
|
||||
bundle_url = re.search(r'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>', resp.text).group(1)
|
||||
(resp := self.get(urljoin("https://play.qobuz.com", bundle_url), **request_overrides)).raise_for_status()
|
||||
app_id = str(re.search(r'production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"(\w{32})', resp.text).group("app_id"))
|
||||
seed_matches, secrets = re.finditer(r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)', resp.text), OrderedDict()
|
||||
for match in seed_matches: seed, timezone = match.group("seed", "timezone"); secrets[timezone] = [seed]
|
||||
secrets.move_to_end(list(secrets.items())[1][0], last=False)
|
||||
info_extras_regex = r'name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'.format(timezones="|".join(timezone.capitalize() for timezone in secrets))
|
||||
for match in re.finditer(info_extras_regex, resp.text): timezone, info, extras = match.group("timezone", "info", "extras"); secrets[timezone.lower()] += [info, extras]
|
||||
for secret_pair in secrets: secrets[secret_pair] = base64.standard_b64decode("".join(secrets[secret_pair])[:-44]).decode("utf-8")
|
||||
if "" in (vals := list(secrets.values())): vals.remove("")
|
||||
QobuzMusicClient.APP_ID, QobuzMusicClient.SECRETS = app_id, vals
|
||||
self.default_headers.update({"X-App-Id": QobuzMusicClient.APP_ID})
|
||||
return QobuzMusicClient.APP_ID, QobuzMusicClient.SECRETS
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}; self._setappidandsecrets(request_overrides=request_overrides)
|
||||
# search rules
|
||||
default_rule = {'query': keyword, 'offset': 0, 'limit': 10}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://www.qobuz.com/api.json/0.2/catalog/search?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithdabyeetsuapi'''
|
||||
def _parsewithdabyeetsuapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
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",}
|
||||
# parse
|
||||
for quality in QobuzMusicClient.MUSIC_QUALITIES:
|
||||
try: (resp := requests.get(f"https://dab.yeet.su/api/stream?trackId={song_id}&quality={quality}", headers=headers, timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
quality = parse_qs(urlparse(download_url).query, keep_blank_values=True).get('fmt') or quality; quality = quality[0] if isinstance(quality, list) else quality
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['performer', 'name'], None)), album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None)), ext='mp3' if quality in {5} else 'flac',
|
||||
file_size_bytes=None, file_size=None, identifier=song_id, duration_s=search_result.get('duration'), duration=seconds2hms(search_result.get('duration')), lyric=None, cover_url=legalizestring(safeextractfromdict(search_result, ['album', 'image', 'large'], 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.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 quality in {5} else 'flac'
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithdabmusicapi'''
|
||||
def _parsewithdabmusicapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
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",}
|
||||
# parse
|
||||
for quality in QobuzMusicClient.MUSIC_QUALITIES:
|
||||
try: (resp := requests.get(f"https://dabmusic.xyz/api/stream?trackId={song_id}&quality={quality}", headers=headers, timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
quality = parse_qs(urlparse(download_url).query, keep_blank_values=True).get('fmt') or quality; quality = quality[0] if isinstance(quality, list) else quality
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['performer', 'name'], None)), album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None)), ext='mp3' if quality in {5} else 'flac',
|
||||
file_size_bytes=None, file_size=None, identifier=song_id, duration_s=search_result.get('duration'), duration=seconds2hms(search_result.get('duration')), lyric=None, cover_url=legalizestring(safeextractfromdict(search_result, ['album', 'image', 'large'], 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.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 quality in {5} else 'flac'
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithafkarxyzapi'''
|
||||
def _parsewithafkarxyzapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
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",}
|
||||
# parse
|
||||
(resp := requests.get(f"https://qbz.afkarxyz.fun/api/track/{song_id}", headers=headers, timeout=10, **request_overrides)).raise_for_status()
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['url'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): return SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]})
|
||||
quality = parse_qs(urlparse(download_url).query, keep_blank_values=True).get('fmt'); quality = quality[0] if isinstance(quality, list) else QobuzMusicClient.MUSIC_QUALITIES[-1]
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['performer', 'name'], None)), album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None)), ext='mp3' if quality in {5} else 'flac',
|
||||
file_size_bytes=None, file_size=None, identifier=song_id, duration_s=search_result.get('duration'), duration=seconds2hms(search_result.get('duration')), lyric=None, cover_url=legalizestring(safeextractfromdict(search_result, ['album', 'image', 'large'], 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.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 quality in {5} else 'flac'
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
if QobuzMusicClient.get_token_func(self.default_headers, "X-User-Auth-Token", "x-user-auth-token"): return SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]})
|
||||
for imp_func in [self._parsewithdabmusicapi, self._parsewithdabyeetsuapi, self._parsewithafkarxyzapi]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]})
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
self._setappidandsecrets(request_overrides=request_overrides); song_info, request_overrides, song_info_flac = SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]}), request_overrides or {}, song_info_flac or SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]})
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for (quality, secret) in list(product(QobuzMusicClient.MUSIC_QUALITIES, QobuzMusicClient.SECRETS)):
|
||||
if song_info_flac.with_valid_download_url and QobuzMusicClient.MUSIC_QUALITIES.index(quality) >= QobuzMusicClient.MUSIC_QUALITIES.index(song_info_flac.raw_data.get('quality', QobuzMusicClient.MUSIC_QUALITIES[-1])): song_info = song_info_flac; break
|
||||
r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{song_id}{(unix_ts := time.time())}{secret}"
|
||||
r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest()
|
||||
params = {"request_ts": unix_ts, "request_sig": r_sig_hashed, "track_id": song_id, "format_id": quality, "intent": "stream"}
|
||||
try: (resp := self.get('https://www.qobuz.com/api.json/0.2/track/getFileUrl', params=params, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp=resp)), ['url'], None)
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['performer', 'name'], None)), album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None)), ext='mp3' if quality in {5} else 'flac',
|
||||
file_size_bytes=None, file_size=None, identifier=song_id, duration_s=download_result.get('duration'), duration=seconds2hms(download_result.get('duration')), lyric=None, cover_url=legalizestring(safeextractfromdict(search_result, ['album', 'image', 'large'], 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.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 quality in {5} else 'flac'
|
||||
if song_info_flac.with_valid_download_url and song_info_flac.largerthan(song_info): song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}; self._setappidandsecrets(request_overrides=request_overrides)
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.get(search_url, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp)['tracks']['items']:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if QobuzMusicClient.get_token_func(self.default_headers, "X-User-Auth-Token", "x-user-auth-token") else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source, raw_data={'quality': QobuzMusicClient.MUSIC_QUALITIES[-1]})
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}; self._setappidandsecrets()
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, QOBUZ_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, page_size, playlist_result_first = [], 1, 500, {}
|
||||
while True:
|
||||
try: (resp := self.get("https://www.qobuz.com/api.json/0.2/playlist/get?", params={"playlist_id": playlist_id, "extra": 'tracks', "offset": (page-1)*page_size, 'limit': page_size}, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['tracks', 'items'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['tracks', 'items'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['tracks', 'total'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["id"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if QobuzMusicClient.get_token_func(self.default_headers, "X-User-Auth-Token", "x-user-auth-token") else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,312 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of QQMusicClient: https://y.qq.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import base64
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from ..utils.hosts import QQ_MUSIC_HOSTS
|
||||
from pathvalidate import sanitize_filepath
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils.qqutils import QQMusicClientUtils, SearchType, Credential, ThirdPartVKeysAPISongFileType, SongFileType, EncryptedSongFileType
|
||||
from ..utils import touchdir, resp2json, seconds2hms, legalizestring, safeextractfromdict, usesearchheaderscookies, extractdurationsecondsfromlrc, useparseheaderscookies, obtainhostname, hostmatchessuffix, optionalimport, cleanlrc, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
def remove_suffix(value: str, suffix: str) -> str:
|
||||
if suffix and value.endswith(suffix):
|
||||
return value[: -len(suffix)]
|
||||
return value
|
||||
|
||||
|
||||
'''QQMusicClient'''
|
||||
class QQMusicClient(BaseMusicClient):
|
||||
source = 'QQMusicClient'
|
||||
def __init__(self, use_encrypted_endpoint: bool = False, **kwargs):
|
||||
super(QQMusicClient, self).__init__(**kwargs)
|
||||
self.use_encrypted_endpoint = use_encrypted_endpoint
|
||||
self.default_search_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', 'Referer': 'https://y.qq.com/', 'Origin': 'https://y.qq.com/',}
|
||||
self.default_parse_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', 'Referer': 'https://y.qq.com/', 'Origin': 'https://y.qq.com/',}
|
||||
self.default_download_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', 'Referer': 'http://y.qq.com',}
|
||||
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 = {'searchid': QQMusicClientUtils.randomsearchid(), 'query': keyword, 'search_type': SearchType.SONG.value, 'num_per_page': self.search_size_per_page, 'page_num': 1, 'highlight': 1, 'grp': 1}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = QQMusicClientUtils.enc_endpoint if self.use_encrypted_endpoint else QQMusicClientUtils.endpoint
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['num_per_page'] = page_size
|
||||
page_rule['page_num'] = int(count // page_size) + 1
|
||||
payload = QQMusicClientUtils.buildrequestdata(params=page_rule, module="music.search.SearchCgiService", method="DoSearchForQQMusicMobile", credential=Credential().fromcookiesdict(self.default_cookies or request_overrides.get('cookies', {})))
|
||||
search_urls.append({'url': base_url, 'data': json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")})
|
||||
if self.use_encrypted_endpoint: search_urls[-1]['params'] = {"sign": QQMusicClientUtils.sign(payload)}
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithvkeysapi'''
|
||||
def _parsewithvkeysapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result.get('mid') or search_result.get('songmid')
|
||||
safe_obtain_filesize_func = lambda meta: (lambda s: (lambda: float(s))() if s.replace('.', '', 1).isdigit() else 0)(remove_suffix(str(meta.get('size', '0.00MB')), 'MB').strip()) if isinstance(meta, dict) else 0
|
||||
to_seconds_func = lambda x: (lambda s: 0 if not s else (lambda p: p[-3]*3600+p[-2]*60+p[-1] if len(p)>=3 else p[0]*60+p[1] if len(p)==2 else p[0] if len(p)==1 else 0)([int(v) for v in re.findall(r'\d+', s.replace(':', ':'))]) if (':' in s or ':' in s) else (lambda h,m,sec,num: (lambda tot: tot if tot>0 else num)(h*3600+m*60+sec))(int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:小时|时|h|hr)', s)) else 0, int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:分钟|分|m|min)', s)) else 0, (int(mo.group(1)) if (mo:=re.search(r'(\d+)\s*(?:秒|s|sec)', s)) else (int(mo.group(1)) if (mo:=re.search(r'(?:分钟|分|m|min)\s*(\d+)\b', s)) else 0)), int(mo.group(0)) if (mo:=re.search(r'\d+', s)) else 0))(str(x).strip().lower())
|
||||
# parse
|
||||
for quality in list(ThirdPartVKeysAPISongFileType.ID_TO_NAME.value.keys())[::-1]:
|
||||
try: (resp := self.get(f"https://api.vkeys.cn/v2/music/tencent/geturl?mid={song_id}&quality={quality}", timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'url'], None)) or (safe_obtain_filesize_func(download_result['data']) < 0.01): continue
|
||||
if not (download_url := download_result['data']['url']) or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.get(f"https://api.vkeys.cn/v2/music/tencent/lyric?mid={song_id}", timeout=10, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp)
|
||||
except Exception: lyric_result = {}
|
||||
duration_in_secs = safeextractfromdict(download_result, ['data', 'interval'], 0)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': lyric_result}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'song'], None)), singers=legalizestring(str(safeextractfromdict(download_result, ['data', 'singer'], '') or '').replace('/', ', ')), album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1],
|
||||
file_size_bytes=None, file_size=remove_suffix(str(safeextractfromdict(download_result, ['data', 'size'], '0.00')), 'MB').strip() + ' MB', identifier=song_id, duration_s=to_seconds_func(duration_in_secs), duration=seconds2hms(to_seconds_func(duration_in_secs)), lyric=cleanlrc(safeextractfromdict(lyric_result, ['data', 'lrc'], 'NULL')) or 'NULL', cover_url=safeextractfromdict(download_result, ['data', 'cover'], 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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithlittleyouziapi'''
|
||||
def _parsewithlittleyouziapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, search_result.get('mid') or search_result.get('songmid')
|
||||
# parse
|
||||
for quality in range(0, 11):
|
||||
try: (resp := self.get(f"https://www.littleyouzi.com/api/v2/qqmusic?mid={song_id}&quality={quality}", timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
download_url: str = safeextractfromdict((download_result := resp2json(resp=resp)), ['data', 'audio'], '')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.get(f"https://www.littleyouzi.com/api/v2/qqmusic?mid={song_id}&lyrics=true", timeout=10, **request_overrides)).raise_for_status(); lyric_result = resp2json(resp=resp)
|
||||
except Exception: lyric_result = {}
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': lyric_result}, source=self.source, song_name=legalizestring(search_result.get('title', None) or search_result.get('songname', None)), singers=legalizestring(', '.join([singer.get('name', '') for singer in (search_result.get('singer', []) or []) if isinstance(singer, dict) and singer.get('name', None)])),
|
||||
album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None) or search_result.get('albumname')), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration_s=search_result.get('interval', 0), duration=seconds2hms(search_result.get('interval', 0)), lyric=cleanlrc(lyric_result.get('content') or 'NULL'),
|
||||
cover_url=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']
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithnkiapi'''
|
||||
def _parsewithnkiapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
decrypt_func, curl_cffi = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8'), optionalimport('curl_cffi')
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result.get('mid') or search_result.get('songmid'), SongInfo(source=self.source)
|
||||
REQUEST_KEYS = ['MjhmZWNlOTI1NDM5YjA1Mjc5MmE5Nzk4OWM4NzBjZWQzODAzYTcxYzZiNTM0ZjcxZTVhNTMzMzhiMmQzMWVmOA==', 'YzRjNGY1ZmMzNmJhZDRjYWNiOTg4MzllMTRmZWE0MDI3N2IzNWVhMmViMWJhYmRhZDdiYmRlMTI4NDAwZjNiMQ==']
|
||||
# parse
|
||||
try: (resp := curl_cffi.requests.get(f'https://api.nki.pw/API/music_open_api.php?mid={song_id}&apikey={decrypt_func(random.choice(REQUEST_KEYS))}', timeout=10, impersonate="chrome131", verify=False, **request_overrides)).raise_for_status()
|
||||
except Exception: (resp := self.get(f'https://api.nki.pw/API/music_open_api.php?mid={song_id}&apikey={decrypt_func(random.choice(REQUEST_KEYS))}', timeout=10, **request_overrides)).raise_for_status()
|
||||
download_url: str = (download_result := resp2json(resp=resp)).get('song_play_url_sq') or download_result.get('song_play_url_pq') or download_result.get('song_play_url_accom') or download_result.get('song_play_url_hq') or download_result.get('song_play_url') or download_result.get('song_play_url_standard') or download_result.get('song_play_url_fq')
|
||||
if not download_url or not str(download_url).startswith('http'): return song_info
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(download_result.get('song_name')), singers=legalizestring(download_result.get('singer_name')), album=legalizestring(download_result.get('album_name')),
|
||||
ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration=download_result.get('duration', '-:-:-'), lyric=cleanlrc(download_result.get('song_lyric', 'NULL')) or 'NULL', cover_url=download_result.get('album_pic', 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']
|
||||
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
|
||||
return song_info
|
||||
'''_parsewithtangapi'''
|
||||
def _parsewithtangapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result.get('mid') or search_result.get('songmid'), SongInfo(source=self.source)
|
||||
# parse
|
||||
(resp := self.get(f'https://tang.api.s01s.cn/music_open_api.php?mid={song_id}', **request_overrides)).raise_for_status()
|
||||
download_url: str = (download_result := resp2json(resp=resp)).get('song_play_url_sq') or download_result.get('song_play_url_pq') or download_result.get('song_play_url_accom') or download_result.get('song_play_url_hq') or download_result.get('song_play_url') or download_result.get('song_play_url_standard') or download_result.get('song_play_url_fq')
|
||||
if not download_url or not str(download_url).startswith('http'): return song_info
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(download_result.get('song_name')), singers=legalizestring(download_result.get('singer_name')), album=legalizestring(download_result.get('album_name')),
|
||||
ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration=download_result.get('duration', '-:-:-'), lyric=cleanlrc(download_result.get('song_lyric', 'NULL')) or 'NULL', cover_url=download_result.get('album_pic', 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']
|
||||
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
|
||||
return song_info
|
||||
'''_parsewithxianyuwapi'''
|
||||
def _parsewithxianyuwapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
decrypt_func, REQUEST_KEYS = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8'), ['c2stOTUwZTc4MTNjMzhjMmUzMWQzOWQ4NzlkMzIwNDg4OTU=', 'c2stNjJjZGIwM2UyMjcwZWIzOTY4Y2NhNzg4MTM5OWY0MTI=']
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result.get('mid') or search_result.get('songmid'), SongInfo(source=self.source)
|
||||
# parse
|
||||
(resp := self.get(f'https://apii.xianyuw.cn/api/v1/qq-music-search?id={song_id}&key={decrypt_func(random.choice(REQUEST_KEYS))}&no_url=0&br=hires', **request_overrides)).raise_for_status()
|
||||
download_url: str = (download_result := resp2json(resp=resp))['data']['url']
|
||||
if not download_url or not str(download_url).startswith('http'): return song_info
|
||||
lyric = cleanlrc(safeextractfromdict(download_result, ['data', 'lrc'], 'NULL')) or 'NULL'
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['data', 'title'], None)), singers=legalizestring(str(safeextractfromdict(download_result, ['data', 'author'], '')).replace('/', ', ')),
|
||||
album=legalizestring(safeextractfromdict(download_result, ['data', 'album'], None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=song_id, duration_s=extractdurationsecondsfromlrc(lyric), duration=seconds2hms(extractdurationsecondsfromlrc(lyric)),
|
||||
lyric=lyric, cover_url=safeextractfromdict(download_result, ['data', 'cover'], 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']
|
||||
if not song_info.album or song_info.album in {'NULL'}: song_info.album = legalizestring(safeextractfromdict(search_result, ['album', 'title'], None) or search_result.get('albumname'))
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
if self.default_cookies or request_overrides.get('cookies'): return SongInfo(source=self.source)
|
||||
for imp_func in [self._parsewithvkeysapi, self._parsewithtangapi, self._parsewithnkiapi, self._parsewithxianyuwapi, self._parsewithlittleyouziapi]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source)
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('mid') or search_result.get('songmid'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
# --non-vip / vip users using enc_endpoint
|
||||
if self.use_encrypted_endpoint:
|
||||
for quality in EncryptedSongFileType.SORTED_QUALITIES.value:
|
||||
params = {"filename": [f"{quality[0]}{song_id}{song_id}{quality[1]}"], "guid": QQMusicClientUtils.randomguid(), "songmid": [song_id], 'songtype': [0]}
|
||||
current_rule = QQMusicClientUtils.buildrequestdata(params=params, module="music.vkey.GetEVkey", method="CgiGetEVkey", credential=Credential().fromcookiesdict(self.default_cookies or request_overrides.get('cookies', {})), common_override={"ct": "19"})
|
||||
try: (resp := self.post(QQMusicClientUtils.enc_endpoint, data=json.dumps(current_rule, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), params={"sign": QQMusicClientUtils.sign(current_rule)}, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp)), ['music.vkey.GetEVkey.CgiGetEVkey', 'data', "midurlinfo", 0, "purl"], "") or safeextractfromdict(download_result, ['music.vkey.GetEVkey.CgiGetEVkey', 'data', "midurlinfo", 0, "wifiurl"], "")
|
||||
ekey = safeextractfromdict(download_result, ['music.vkey.GetEVkey.CgiGetEVkey', 'data', "midurlinfo", 0, "ekey"], "")
|
||||
if not download_url: continue
|
||||
download_url = QQMusicClientUtils.music_domain + download_url
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'ekey': ekey}, source=self.source, song_name=legalizestring(search_result.get('title') or search_result.get('songname')), singers=legalizestring(', '.join([singer.get('name') for singer in (search_result.get('singer', []) or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None) or search_result.get('albumname', None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=str(song_id), duration_s=search_result.get('interval', 0), duration=seconds2hms(search_result.get('interval', 0)), lyric=None, cover_url=None,
|
||||
download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
||||
)
|
||||
song_info.cover_url = f"https://y.gtimg.cn/music/photo_new/T002R800x800M000{safeextractfromdict(search_result, ['album', 'mid'], '') or search_result.get('albummid')}.jpg"
|
||||
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']
|
||||
'''
|
||||
# encrypted audio extension, not conduct this part
|
||||
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
|
||||
# --non-vip / vip users using endpoint
|
||||
else:
|
||||
for quality in SongFileType.SORTED_QUALITIES.value:
|
||||
params = {"filename": [f"{quality[0]}{song_id}{song_id}{quality[1]}"], "guid": QQMusicClientUtils.randomguid(), "songmid": [song_id], 'songtype': [0]}
|
||||
current_rule = QQMusicClientUtils.buildrequestdata(params=params, module="music.vkey.GetVkey", method="UrlGetVkey", credential=Credential().fromcookiesdict(self.default_cookies or request_overrides.get('cookies', {})), common_override={"ct": "19"})
|
||||
try: (resp := self.post(QQMusicClientUtils.endpoint, data=json.dumps(current_rule, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = safeextractfromdict((download_result := resp2json(resp)), ['music.vkey.GetVkey.UrlGetVkey', 'data', "midurlinfo", 0, "purl"], "") or safeextractfromdict(download_result, ['music.vkey.GetVkey.UrlGetVkey', 'data', "midurlinfo", 0, "wifiurl"], "")
|
||||
if not download_url: continue
|
||||
download_url = QQMusicClientUtils.music_domain + download_url
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title') or search_result.get('songname')), singers=legalizestring(', '.join([singer.get('name') for singer in (search_result.get('singer', []) or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(safeextractfromdict(search_result, ['album', 'title'], None) or search_result.get('albumname', None)), ext=download_url.split('?')[0].split('.')[-1], file_size_bytes=None, file_size=None, identifier=str(song_id), duration_s=search_result.get('interval', 0), duration=seconds2hms(search_result.get('interval', 0)), lyric=None,
|
||||
cover_url=None, download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
||||
)
|
||||
song_info.cover_url = f"https://y.gtimg.cn/music/photo_new/T002R800x800M000{safeextractfromdict(search_result, ['album', 'mid'], '') or search_result.get('albummid')}.jpg"
|
||||
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_flac.with_valid_download_url and song_info_flac.largerthan(song_info): song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
params = {'songmid': str(song_id), 'g_tk': '5381', 'loginUin': '0', 'hostUin': '0', 'format': 'json', 'inCharset': 'utf8', 'outCharset': 'utf-8', 'platform': 'yqq'}
|
||||
lyric_request_overrides = copy.deepcopy(request_overrides); lyric_request_overrides.pop('headers', {})
|
||||
try: (resp := self.get('https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg', headers={'Referer': 'https://y.qq.com/portal/player.html'}, params=params, **lyric_request_overrides)).raise_for_status(); lyric = (lyric_result := resp2json(resp)).get('lyric'); lyric = 'NULL' if not lyric else cleanlrc(base64.b64decode(lyric).decode('utf-8'))
|
||||
except Exception: lyric_result, lyric = {}, "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
|
||||
# return
|
||||
return song_info
|
||||
'''_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
|
||||
search_meta, request_overrides = copy.deepcopy(search_url), request_overrides or {}; search_url = search_meta.pop('url')
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.post(search_url, **search_meta, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp)['music.search.SearchCgiService.DoSearchForQQMusicMobile']['data']['body']['item_song']:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
request_overrides.setdefault('timeout', (10, 30))
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlparse(playlist_url).query, keep_blank_values=False).get('id')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = remove_suffix(remove_suffix(urlparse(playlist_url).path.strip('/').split('/')[-1], '.html'), '.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, QQ_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
(resp := self.get("https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg", headers={"Referer": f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"}, params={"disstid": str(playlist_id), "type": "1", "json": "1", "utf8": "1", "onlysong": "0", "format": "json"}, **request_overrides)).raise_for_status()
|
||||
tracks_in_playlist = (safeextractfromdict((playlist_result := resp2json(resp=resp)), ['cdlist', 0, 'songlist'], []) or safeextractfromdict(playlist_result, ['cdlist', 0, 'list'], []) or safeextractfromdict(playlist_result, ['songlist'], []) or [])
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['cdlist', 0, 'dissname'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,173 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of SodaMusicClient: https://www.douyin.com/qishui/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import copy
|
||||
import json_repair
|
||||
from pathlib import Path
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import SODA_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from ..utils.sodautils import AudioDecryptor, SodaTimedLyricsParser
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, byte2mb, resp2json, usesearchheaderscookies, safeextractfromdict, seconds2hms, usedownloadheaderscookies, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester, SongInfoUtils
|
||||
|
||||
|
||||
'''SodaMusicClient'''
|
||||
class SodaMusicClient(BaseMusicClient):
|
||||
source = 'SodaMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(SodaMusicClient, 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/143.0.0.0 Safari/537.36"}
|
||||
self.default_parse_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_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()
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
super()._download(song_info=song_info, request_overrides=request_overrides, downloaded_song_infos=[], progress=progress, song_progress_id=song_progress_id, auto_supplement_song=False)
|
||||
with open(song_info.save_path, "rb") as fp: file_data = bytearray(fp.read())
|
||||
output_filepath = (output_filepath := Path(song_info.save_path)).parent / f'{output_filepath.stem}.m4a'
|
||||
AudioDecryptor.decrypt(file_data=file_data, play_auth=song_info.raw_data['play_auth'], output_filepath=str(output_filepath))
|
||||
if not os.path.samefile(song_info.save_path, str(output_filepath)): os.remove(song_info.save_path)
|
||||
song_info._save_path = str(output_filepath); downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
return downloaded_song_infos
|
||||
'''_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)
|
||||
# search rules
|
||||
default_rule = {
|
||||
'aid': '386088', 'app_name': 'luna_pc', 'region': 'cn', 'geo_region': 'cn', 'os_region': 'cn', 'sim_region': '', 'device_id': '1088932190113307', 'cdid': '', 'iid': '2332504177791808', 'version_name': '3.0.0', 'version_code': '30000000', 'channel': 'official', 'build_mode': 'master', 'network_carrier': '', 'ac': 'wifi', 'tz_name': 'Asia/Shanghai',
|
||||
'resolution': '', 'device_platform': 'windows', 'device_type': 'Windows', 'os_version': 'Windows 11 Home China', 'fp': '1088932190113307', 'q': keyword, 'cursor': 0, 'search_id': '4ee2bc52-db9b-42c3-85cf-cdac2fe02efe', 'search_method': 'input', 'debug_params': '', 'from_search_id': 'aa21093-d49e-4d29-b6c7-548b170d12a0', 'search_scene': '',
|
||||
}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://api.qishui.com/luna/pc/search/track?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['cursor'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := safeextractfromdict(search_result, ['entity', 'track', 'id'], None))): return song_info
|
||||
rank_audio_func = lambda video_list: sorted(video_list, key=lambda x: (x.get('Size'), x.get('Bitrate')), reverse=True)
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
(resp := self.get(f'https://api.qishui.com/luna/pc/track_v2?track_id={song_id}&media_type=track&queue_type=', **request_overrides)).raise_for_status()
|
||||
(resp := self.get((download_result := resp2json(resp))['track_player']['url_player_info'], **request_overrides)).raise_for_status()
|
||||
download_result['url_player_info_response'] = resp2json(resp)
|
||||
audios_sorted: list[dict] = rank_audio_func(safeextractfromdict(download_result, ['url_player_info_response', 'Result', 'Data', 'PlayInfoList'], []) or [])
|
||||
audios_sorted: list[dict] = [a for a in audios_sorted if (a.get('MainPlayUrl') or a.get('BackupPlayUrl'))]
|
||||
for audio_sorted in audios_sorted:
|
||||
download_url = audio_sorted.get('MainPlayUrl') or audio_sorted.get('BackupPlayUrl'); play_auth = safeextractfromdict(audio_sorted, ['PlayAuth'], '')
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}, 'play_auth': play_auth}, source=self.source, song_name=legalizestring(safeextractfromdict(search_result, ['entity', 'track', 'name'], None)), singers=legalizestring(', '.join([singer.get('name') for singer in (safeextractfromdict(search_result, ['entity', 'track', 'artists'], []) or []) if isinstance(singer, dict) and singer.get('name')])), album=legalizestring(safeextractfromdict(search_result, ['entity', 'track', 'album', 'name'], None)), ext=audio_sorted.get('Format', 'm4a'), file_size_bytes=audio_sorted.get('Size', 0), file_size=byte2mb(audio_sorted.get('Size', 0)),
|
||||
identifier=str(song_id), duration_s=audio_sorted.get('Duration'), duration=seconds2hms(audio_sorted.get('Duration')), lyric=cleanlrc(SodaTimedLyricsParser.tolrclinelevel(SodaTimedLyricsParser.parsetimedlyrics(safeextractfromdict(download_result, ['lyric', 'content'], '')))) or 'NULL', cover_url=str(safeextractfromdict(search_result, ['entity', 'track', 'album', 'url_cover', 'urls', 0], '')) + str(safeextractfromdict(search_result, ['entity', 'track', 'album', 'url_cover', 'uri'], '')) + '~c5_375x375.jpg', 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.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: return song_info
|
||||
# supplement lyric results
|
||||
try:
|
||||
(resp := self.get(f'https://music.douyin.com/qishui/share/track?track_id={song_id}', **request_overrides)).raise_for_status()
|
||||
lyric_result = json_repair.loads(re.search(r'_ROUTER_DATA\s*=\s*({[\s\S]*?});', resp.text).group(1).strip())
|
||||
sentences, lrc_list = lyric_result['loaderData']['track_page']['audioWithLyricsOption']['lyrics']['sentences'], []
|
||||
for sentence in sentences:
|
||||
if not isinstance(sentence, dict): continue
|
||||
start_ms = sentence.get('startMs', 0); sentence_text = "".join([w.get('text', '') for w in sentence.get('words', []) if isinstance(w, dict)])
|
||||
minutes, seconds, m_seconds = start_ms // 60000, (start_ms % 60000) // 1000, start_ms % 1000; time_tag = f"[{minutes:02d}:{seconds:02d}.{m_seconds:03d}]"
|
||||
lrc_list.append(f"{time_tag}{sentence_text}")
|
||||
lyric = cleanlrc("\n".join(lrc_list)) or 'NULL'
|
||||
except Exception: lyric_result, lyric = {}, '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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in resp2json(resp)['result_groups'][0]['data']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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 song_infos
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
try: playlist_id, song_infos = parse_qs(urlparse(playlist_url).query, keep_blank_values=False).get('playlist_id')[0], []; assert playlist_id
|
||||
except: playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, SODA_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, page_size, playlist_result_first = [], 1, 20, {}
|
||||
while True:
|
||||
params = {'playlist_id': playlist_id, 'cursor': str(page_size * (page - 1)), 'cnt': str(page_size), 'aid': '386088', 'device_platform': 'web', 'channel': 'pc_web'}
|
||||
try: (resp := self.get(f"https://api.qishui.com/luna/pc/playlist/detail?", params=params, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['media_resources'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['media_resources'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['playlist', 'count_tracks'], 0)) <= len(tracks_in_playlist)): break
|
||||
tracks_in_playlist = list({d["id"]: d for d in tracks_in_playlist}.values())
|
||||
for track_idx in range(len(tracks_in_playlist)):
|
||||
try: tracks_in_playlist[track_idx]['entity']['track'] = safeextractfromdict(tracks_in_playlist[track_idx], ['entity', 'track_wrapper', 'track'], {})
|
||||
except Exception: continue
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['playlist', 'title'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,179 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of SoundCloudMusicClient: https://soundcloud.com/discover
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from ..utils.hosts import SOUNDCLOUD_MUSIC_HOSTS
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, hostmatchessuffix, obtainhostname, useparseheaderscookies, SongInfo, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''SoundCloudMusicClient'''
|
||||
class SoundCloudMusicClient(BaseMusicClient):
|
||||
source = 'SoundCloudMusicClient'
|
||||
CLIENT_ID = None
|
||||
def __init__(self, **kwargs):
|
||||
super(SoundCloudMusicClient, self).__init__(**kwargs)
|
||||
if self.default_search_cookies: assert ("oauth_token" in self.default_search_cookies), '"oauth_token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#soundcloud-music-download'
|
||||
if self.default_parse_cookies: assert ("oauth_token" in self.default_parse_cookies), '"oauth_token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#soundcloud-music-download'
|
||||
if self.default_download_cookies: assert ("oauth_token" in self.default_download_cookies), '"oauth_token" should be configured, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#soundcloud-music-download'
|
||||
self.default_search_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_parse_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_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
|
||||
if self.default_search_cookies: self.default_search_headers.update({'Authorization': self.default_search_cookies["oauth_token"]})
|
||||
if self.default_parse_cookies: self.default_parse_headers.update({'Authorization': self.default_parse_cookies["oauth_token"]})
|
||||
if self.default_download_cookies: self.default_download_headers.update({'Authorization': self.default_download_cookies["oauth_token"]})
|
||||
self._initsession()
|
||||
'''_setclientid'''
|
||||
def _setclientid(self, request_overrides: dict = None):
|
||||
if SoundCloudMusicClient.CLIENT_ID: return
|
||||
request_overrides = request_overrides or {}
|
||||
try: (resp := self.session.get('https://soundcloud.com/', **request_overrides)).raise_for_status()
|
||||
except: SoundCloudMusicClient.CLIENT_ID = '9jZvetLfDs6An08euQgJ0lYlHkKdGFzV'; return
|
||||
script_urls = re.findall(r'<script[^>]+src="([^"]+)"', resp.text)
|
||||
for url in reversed(script_urls):
|
||||
try: resp = self.session.get(url, **request_overrides); m = re.search(r'client_id\s*:\s*"([0-9a-zA-Z]{32})"', resp.text) if resp.status_code == 200 else None
|
||||
except Exception: continue
|
||||
if m: SoundCloudMusicClient.CLIENT_ID = m.group(1); return SoundCloudMusicClient.CLIENT_ID
|
||||
SoundCloudMusicClient.CLIENT_ID = '9jZvetLfDs6An08euQgJ0lYlHkKdGFzV'; return
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
self._setclientid(request_overrides=request_overrides)
|
||||
# search rules
|
||||
default_rule = {'q': keyword, 'sc_a_id': 'ab15798461680579b387acf67441b40149e528cd', 'facet': 'genre', 'user_id': '704923-225181-486085-807554', 'client_id': SoundCloudMusicClient.CLIENT_ID, 'limit': '20', 'offset': '0', 'linked_partitioning': '1', 'app_version': '1769771069', 'app_locale': 'en'}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://api-v2.soundcloud.com/search/tracks?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
self._setclientid(request_overrides=request_overrides); song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
guess_codec_func = lambda t: ((lambda preset, mime: "opus" if ("opus" in preset or "opus" in mime) else "aac" if ("aac" in preset or "mp4a" in mime or "audio/mp4" in mime or "m4a" in mime) else "mp3" if ("mp3" in preset or "audio/mpeg" in mime) else "abr" if ("abr" in preset) else "unknown")((safeextractfromdict(t, ["preset"], "") or "").lower(), (safeextractfromdict(t, ["format", "mime_type"], "") or "").lower()))
|
||||
guess_bitrate_kbps_func = lambda t: (lambda preset: (lambda m: int(m.group(1)) if m else 128 if preset == "mp3_0_1" else 64 if preset == "opus_0_0" else 128 if preset.startswith("abr") else 0)(re.search(r"(\d+)\s*k", preset)))((safeextractfromdict(t, ["preset"], "") or "").lower())
|
||||
quality_rank_func = lambda t: {"hq": 2, "sq": 1}.get((safeextractfromdict(t, ["quality"], "") or "").lower(), 0)
|
||||
codec_rank_func = lambda codec: {"opus": 4, "aac": 3, "abr": 2, "mp3": 1, "unknown": 0}.get((codec or "").lower(), 0)
|
||||
protocol_rank_func = lambda t: {"progressive": 2, "hls": 1}.get((safeextractfromdict(t, ["format", "protocol"], "") or "").lower(), 0)
|
||||
sort_key_func = lambda t: (lambda c, br: (quality_rank_func(t), br, codec_rank_func(c), protocol_rank_func(t)))(guess_codec_func(t), guess_bitrate_kbps_func(t))
|
||||
# supplement incomplete tracks
|
||||
if not safeextractfromdict(search_result, ['media', 'transcodings'], []): search_result = resp2json(self.get(f"https://api-v2.soundcloud.com/tracks/{song_id}", params={"client_id": SoundCloudMusicClient.CLIENT_ID}, **request_overrides))
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for transcoding in sorted((safeextractfromdict(search_result, ['media', 'transcodings'], []) or []), key=sort_key_func, reverse=True):
|
||||
if not isinstance(transcoding, dict): continue
|
||||
preset, mime_type = transcoding.get('preset', '') or '', safeextractfromdict(transcoding, ['format', 'mime_type'], '') or ''
|
||||
download_url, protocol = transcoding.get('url', '') or '', safeextractfromdict(transcoding, ['format', 'protocol'], '') or ''
|
||||
if str(protocol).startswith(('ctr-', 'cbc-')): continue # TODO: Solve DRM issues in SoundCloud
|
||||
ext = (('opus' if ('opus' in preset or 'opus' in mime_type) else None) or ('m4a' if ('aac' in preset or 'm4a' in mime_type) else None) or 'mp3')
|
||||
if f"{protocol}_{preset}" in {"original_download"}:
|
||||
try: (resp := self.get(f'https://api-v2.soundcloud.com/tracks/{song_id}/download', params={'client_id': SoundCloudMusicClient.CLIENT_ID}, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = (download_result := resp2json(resp=resp)).get('redirectUri')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
else:
|
||||
try: (resp := self.get(download_url, params={'client_id': SoundCloudMusicClient.CLIENT_ID}, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url = (download_result := resp2json(resp=resp)).get('url')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
if str(protocol).lower() in {'hls'}:
|
||||
try: (resp := self.get(download_url, allow_redirects=True, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url_status = {'ok': True}
|
||||
else:
|
||||
download_url_status = self.audio_link_tester.test(download_url, request_overrides)
|
||||
try: duration_in_secs = int(float(safeextractfromdict(search_result, ['duration'], 0)) / 1000)
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(safeextractfromdict(search_result, ['publisher_metadata', 'artist'], None) or safeextractfromdict(search_result, ['user', 'username'], None)), album=legalizestring(safeextractfromdict(search_result, ['publisher_metadata', 'album_title'], None)),
|
||||
ext=ext, file_size_bytes=None, file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=search_result.get('artwork_url'), download_url=download_url, download_url_status=download_url_status
|
||||
)
|
||||
if str(protocol).lower() in {'hls'}: song_info.protocol, song_info.file_size = 'HLS', 'HLS'
|
||||
else:
|
||||
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.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
|
||||
# supplement lyric results
|
||||
lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}; self._setclientid(request_overrides=request_overrides)
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
(resp := self.get(search_url, **request_overrides)).raise_for_status()
|
||||
for search_result in resp2json(resp)['collection']:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}; self._setclientid()
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, SOUNDCLOUD_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
(resp := self.get("https://api-v2.soundcloud.com/resolve", params={"url": playlist_url, "client_id": SoundCloudMusicClient.CLIENT_ID}, **request_overrides)).raise_for_status()
|
||||
tracks_in_playlist = (playlist_result := resp2json(resp=resp))['tracks']; playlist_id = playlist_result['id']
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result, ['title'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,235 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of SpotifyMusicClient: https://open.spotify.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
from bs4 import BeautifulSoup
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from ..utils.hosts import SPOTIFY_MUSIC_HOSTS
|
||||
from ..utils.spotifyutils import SpotifyMusicClientPlaylistUtils, SpotifyMusicClientSearchUtils
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import byte2mb, touchdir, legalizestring, resp2json, seconds2hms, usesearchheaderscookies, safeextractfromdict, naiveguessextfromaudiobytes, useparseheaderscookies, obtainhostname, hostmatchessuffix, extractdurationsecondsfromlrc, SongInfo, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''SpotifyMusicClient'''
|
||||
class SpotifyMusicClient(BaseMusicClient):
|
||||
source = 'SpotifyMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(SpotifyMusicClient, 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/145.0.0.0 Safari/537.36", "Accept": "application/json", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://open.spotify.com/", "Origin": "https://open.spotify.com/"}
|
||||
self.default_parse_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", "Accept": "application/json", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://open.spotify.com/", "Origin": "https://open.spotify.com/"}
|
||||
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 {}
|
||||
# construct search urls based on search rules
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
search_urls.append({'api': SpotifyMusicClientSearchUtils.searchbykeyword, 'inputs': {'session': copy.deepcopy(self.session), 'query': keyword, 'limit': page_size, 'offset': count, 'rule': copy.deepcopy(rule), 'request_overrides': request_overrides}})
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithspotisaverapi'''
|
||||
def _parsewithspotisaverapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
headers = {
|
||||
"referer": "https://spotisaver.net/en1", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "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",
|
||||
}
|
||||
# parse
|
||||
(resp := self.get(f'https://spotisaver.net/api/get_playlist.php?id={song_id}&type=track&lang=en', headers=headers, **request_overrides)).raise_for_status()
|
||||
payload = {"track": (download_result := resp2json(resp=resp))["tracks"][0], "download_dir": "downloads", "filename_tag": "SPOTISAVER", "user_ip": "2601:1e23:dac0:b1d7:39a4:640e:4700:01c7", "is_premium": "true"}
|
||||
(resp := self.post('https://spotisaver.net/api/download_track.php', json=payload, headers=headers, **request_overrides)).raise_for_status()
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['tracks', 0, 'duration_ms'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['tracks', 0, 'name'], None)), singers=legalizestring(', '.join(safeextractfromdict(download_result, ['tracks', 0, 'artists'], []) or [])), album=legalizestring(safeextractfromdict(download_result, ['tracks', 0, 'album'], None)), ext=naiveguessextfromaudiobytes(resp.content),
|
||||
file_size_bytes=resp.content.__sizeof__(), file_size=byte2mb(resp.content.__sizeof__()), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric=None, cover_url=safeextractfromdict(download_result, ['tracks', 0, 'image', 'url'], None), download_url=None, downloaded_contents=resp.content, download_url_status={'ok': True},
|
||||
)
|
||||
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithspotubedlapi'''
|
||||
def _parsewithspotubedlapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
headers = {
|
||||
"referer": "https://spotubedl.com/", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "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"
|
||||
}
|
||||
# parse
|
||||
(resp := self.get(f'https://spotubedl.com/api/metadata/{song_id}', headers=headers, **request_overrides)).raise_for_status()
|
||||
vid = parse_qs(urlparse(str((download_result := resp2json(resp=resp))['youtube_url'])).query, keep_blank_values=True).get('v')[0]
|
||||
(resp := self.get(f'https://spotubedl.com/api/download/{vid}?engine=v1&format=mp3&quality=320', headers=headers, **request_overrides)).raise_for_status()
|
||||
download_url = resp2json(resp=resp)['url']; download_result['youtube_resp'] = resp2json(resp=resp)
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(', '.join(download_result.get('artists', []) or [])),
|
||||
album=legalizestring(download_result.get('album_name', None)), ext='mp3', file_size_bytes=None, file_size=None, identifier=song_id, duration_s=download_result.get('duration'), duration=seconds2hms(download_result.get('duration')),
|
||||
lyric=None, cover_url=download_result.get('cover_url'), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), default_download_headers=headers,
|
||||
)
|
||||
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
|
||||
return song_info
|
||||
'''_parsewithspotidownloaderapi'''
|
||||
def _parsewithspotidownloaderapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id = request_overrides or {}, str(search_result['id'])
|
||||
# fetch token
|
||||
(resp := self.get('https://spdl.afkarxyz.fun/token', headers={"User-Agent": "CharlesPikachu-musicdl"}, **request_overrides)).raise_for_status()
|
||||
session_token = (download_result := resp2json(resp=resp))['token']
|
||||
headers = {"Authorization": f"Bearer {session_token}", "Content-Type": "application/json", "Origin": "https://spotidownloader.com", "Referer": "https://spotidownloader.com/"}
|
||||
# parse
|
||||
(resp := self.post(f'https://api.spotidownloader.com/download', headers=headers, json={"id": song_id}, **request_overrides)).raise_for_status()
|
||||
download_result.update(resp2json(resp=resp))
|
||||
download_urls: list[str] = [u for u in [download_result.get('linkFlac'), download_result.get('link')] if u and str(u).startswith('http')]
|
||||
try: duration_in_secs = float(safeextractfromdict(search_result, ['item', 'data', 'duration', 'totalMilliseconds'], 0) or safeextractfromdict(search_result, ['itemV2', 'data', 'trackDuration', 'totalMilliseconds'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
for download_url in download_urls:
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(safeextractfromdict(download_result, ['metadata', 'title'], None)), singers=legalizestring(safeextractfromdict(download_result, ['metadata', 'artists'], None)), album=legalizestring(safeextractfromdict(download_result, ['metadata', 'album'], 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, ['metadata', 'cover'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides),
|
||||
)
|
||||
if song_info.ext in {'m4s', 'mp4'}: song_info.ext = 'm4a'
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithspotmateapi'''
|
||||
def _parsewithspotmateapi(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, session = request_overrides or {}, str(search_result['id']), copy.deepcopy(self.session)
|
||||
session.headers = {'user-agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36'}
|
||||
(resp := session.get('https://spotmate.online/en', **request_overrides)).raise_for_status()
|
||||
cookies = "; ".join([f"{cookie.name}={cookie.value}" for cookie in session.cookies])
|
||||
soup = BeautifulSoup(resp.text, 'lxml'); meta_tag = soup.find('meta', attrs={'name': 'csrf-token'}); csrf_token = meta_tag.get('content')
|
||||
headers = {
|
||||
'authority': 'spotmate.online', 'accept': '*/*', 'accept-language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7', 'origin': 'https://spotmate.online', 'referer': 'https://spotmate.online/en', 'x-csrf-token': csrf_token,
|
||||
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132"', 'sec-ch-ua-mobile': '?1', 'sec-ch-ua-platform': '"Android"', 'sec-fetch-dest': 'empty', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json',
|
||||
'user-agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36', 'cookie': cookies, 'sec-fetch-mode': 'cors',
|
||||
}
|
||||
# parse
|
||||
(resp := session.post('https://spotmate.online/getTrackData', json={'spotify_url': f'https://open.spotify.com/track/{song_id}'}, headers=headers, **request_overrides)).raise_for_status()
|
||||
download_result = resp2json(resp=resp)
|
||||
(resp := session.post('https://spotmate.online/convert', json={'urls': f'https://open.spotify.com/track/{song_id}'}, headers=headers, **request_overrides)).raise_for_status()
|
||||
download_result['convert'] = resp2json(resp=resp); download_url = download_result['convert']['url']
|
||||
try: duration_in_secs = float(safeextractfromdict(download_result, ['duration_ms'], 0)) / 1000
|
||||
except Exception: duration_in_secs = 0
|
||||
try: ext = parse_qs(urlparse(download_url).query, keep_blank_values=True).get('format')[0]
|
||||
except Exception: ext = 'mp3'
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(download_result.get('name', '')), singers=legalizestring(', '.join([singer.get('name') for singer in (download_result.get('artists', []) or []) if isinstance(singer, dict) and singer.get('name')])),
|
||||
album=legalizestring(safeextractfromdict(search_result, ['itemV2', 'data', 'albumOfTrack', 'name'], None) or safeextractfromdict(search_result, ['item', 'data', 'albumOfTrack', 'name'], None)), ext=ext, file_size=None, identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL',
|
||||
cover_url=safeextractfromdict(download_result, ['album', 'images', 0, 'url'], None), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url, request_overrides), default_download_headers=headers,
|
||||
)
|
||||
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
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
for imp_func in [self._parsewithspotisaverapi, self._parsewithspotidownloaderapi, self._parsewithspotmateapi, self._parsewithspotubedlapi]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source)
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('id'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
pass # TODO: Solve DRM Issues in Spotify
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
if not song_info.duration or song_info.duration == '-:-:-': song_info.duration = seconds2hms(extractdurationsecondsfromlrc(song_info.lyric))
|
||||
# return
|
||||
return song_info
|
||||
'''_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, search_api, search_api_inputs = request_overrides or {}, search_url['api'], search_url['inputs']
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
for search_result in safeextractfromdict((search_resp := search_api(**search_api_inputs)), ['data', 'searchV2', 'tracksV2', 'items'], []) or safeextractfromdict(search_resp, ['data', 'searchV2', 'tracks', 'items'], []):
|
||||
search_result['id'] = safeextractfromdict(search_result, ['item', 'data', 'id'], None)
|
||||
if not search_result['id']: search_result['id'] = str(safeextractfromdict(search_result, ['item', 'data', 'uri'], '')).removeprefix('spotify:track:')
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, SPOTIFY_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
playlist_result_first, tracks_in_playlist = SpotifyMusicClientPlaylistUtils.parse(copy.deepcopy(self.session), playlist_id=playlist_id, request_overrides=request_overrides)
|
||||
tracks_in_playlist = list({d["id"]: d for d in tracks_in_playlist}.values())
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=track_info, request_overrides=request_overrides)
|
||||
lossless_quality_is_sufficient = False if self.default_cookies or request_overrides.get('cookies') else True
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=song_info_flac, lossless_quality_is_sufficient=lossless_quality_is_sufficient, request_overrides=request_overrides)
|
||||
except Exception: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['data', 'playlistV2', 'name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,169 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of StreetVoiceMusicClient: https://www.streetvoice.cn/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import os
|
||||
import copy
|
||||
import time
|
||||
from bs4 import BeautifulSoup
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import STREETVOICE_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urljoin, urlparse, urlsplit, urlunsplit
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils import touchdir, legalizestring, resp2json, usesearchheaderscookies, seconds2hms, safeextractfromdict, useparseheaderscookies, obtainhostname, hostmatchessuffix, cleanlrc, SongInfo, AudioLinkTester
|
||||
|
||||
|
||||
'''StreetVoiceMusicClient'''
|
||||
class StreetVoiceMusicClient(BaseMusicClient):
|
||||
source = 'StreetVoiceMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(StreetVoiceMusicClient, 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/121.0.0.0 Safari/537.36 Edg/121.0.0.0", "Referer": "https://www.streetvoice.cn/", "x-requested-with": "XMLHttpRequest"}
|
||||
self.default_parse_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", "Referer": "https://www.streetvoice.cn/", "x-requested-with": "XMLHttpRequest"}
|
||||
self.default_download_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", "Referer": "https://www.streetvoice.cn/", "x-requested-with": "XMLHttpRequest"}
|
||||
self.default_headers = self.default_search_headers
|
||||
self._initsession()
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
self.search_size_per_page = min(10, self.search_size_per_page)
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
# search rules
|
||||
default_rule = {'page': 1, 'q': keyword, 'type': 'song', '_pjax': '#pjax-container'}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://www.streetvoice.cn/search/?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['page'] = int(count // page_size) + 1
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('song_id'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
try: (resp := self.get(f"https://www.streetvoice.cn/api/v5/song/{song_id}/?_={int(time.time() * 1000)}", **request_overrides)).raise_for_status()
|
||||
except Exception: return song_info
|
||||
try: (hls_resp := self.post(f"https://www.streetvoice.cn/api/v5/song/{song_id}/hls/file/", **request_overrides)).raise_for_status()
|
||||
except Exception: return song_info
|
||||
(download_result := resp2json(resp=resp))['hls/file'] = resp2json(resp=hls_resp)
|
||||
if not (download_url := download_result['hls/file']['file']) or not str(download_url).startswith('http'): return song_info
|
||||
try: (resp := self.session.head(download_url, **request_overrides)).raise_for_status(); download_url_status = {'ok': True}
|
||||
except Exception: return song_info
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': download_result, 'lyric': {}}, source=self.source, song_name=legalizestring(download_result.get('name')), singers=legalizestring(safeextractfromdict(download_result, ['user', 'profile', 'nickname'], None)),
|
||||
album=legalizestring(safeextractfromdict(download_result, ['album', 'name'], None)), ext=download_url.removesuffix('.m3u8').split('?')[0].split('.')[-1], file_size_bytes=None, file_size='HLS', identifier=song_id, duration_s=download_result.get('length'),
|
||||
duration=seconds2hms(download_result.get('length')), lyric=cleanlrc(safeextractfromdict(download_result, ['lyrics'], 'NULL')), cover_url=download_result.get('image'), download_url=download_url, download_url_status=download_url_status, protocol='HLS'
|
||||
)
|
||||
if (song_info.ext not in AudioLinkTester.VALID_AUDIO_EXTS): song_info.ext = 'mp3'
|
||||
# return
|
||||
return song_info
|
||||
'''_extractonesearchpage'''
|
||||
def _extractonesearchpage(self, html_text: str, page_url: str):
|
||||
soup, search_results = BeautifulSoup(html_text, "lxml"), []
|
||||
for li in soup.select("ul.list-group-song li.work-item.item_box"):
|
||||
title_a = li.select_one(".work-item-info h4 a"); artist_a = li.select_one(".work-item-info h5 a")
|
||||
img = li.select_one(".cover-block img"); play_btn = li.select_one("button.js-search[data-id]")
|
||||
like_btn = li.select_one("button.js-like-btn[data-like-count]"); like_raw = like_btn.get("data-like-count") if like_btn else None
|
||||
song_href = title_a.get("href") if title_a else None; artist_href = artist_a.get("href") if artist_a else None
|
||||
search_results.append({
|
||||
"song_id": play_btn.get("data-id") if play_btn else None, "title": title_a.get_text(strip=True) if title_a else None, "artist": artist_a.get_text(strip=True) if artist_a else None, "song_url": urljoin(page_url, song_href) if song_href else None,
|
||||
"artist_url": urljoin(page_url, artist_href) if artist_href else None, "cover_url": img.get("src") if img else None, "like_raw": like_raw,
|
||||
})
|
||||
return search_results
|
||||
'''_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()
|
||||
for search_result in self._extractonesearchpage(resp.text, "https://www.streetvoice.cn/"):
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''_extractplaylistpagesongs'''
|
||||
def _extractplaylistpagesongs(self, html_text, base_url='https://streetvoice.cn'):
|
||||
soup, songs, seen = BeautifulSoup(html_text, 'lxml'), [], set()
|
||||
for li in soup.select('#item_box_list_1 li.item_box'):
|
||||
artist_a = li.select_one('.work-item-info h5 a') or li.select_one('.work-item-info h4 a'); num_el = li.select_one('.work-item-number h4')
|
||||
if not (song_a := li.select_one('.work-item-info h4 a[href*="/songs/"]')): continue
|
||||
if (url := urljoin(base_url, song_a['href'])) in seen: continue
|
||||
seen.add(url); songs.append({'index': int(num_el.get_text(strip=True)) if num_el else None, 'title': ' '.join(song_a.stripped_strings), 'song_url': url, 'song_id': urlparse(url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), 'artist': artist_a.get_text(strip=True) if artist_a else None, 'artist_url': urljoin(base_url, artist_a['href']) if artist_a and artist_a.has_attr('href') else None})
|
||||
return songs
|
||||
'''_extractplaylistname'''
|
||||
def _extractplaylistname(self, html_text):
|
||||
soup = BeautifulSoup(html_text, 'lxml')
|
||||
for sel in ['.work-page-header-wrapper h1', '#sticky .work-item-info h4', 'title']:
|
||||
node = soup.select_one(sel)
|
||||
if not (node := soup.select_one(sel)): continue
|
||||
text = ' '.join(node.stripped_strings)
|
||||
if sel == 'title': text = text.split(' - ')[0].strip()
|
||||
if text: return text
|
||||
return None
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_url = urlunsplit(urlsplit(playlist_url)._replace(query="", fragment=""))
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, STREETVOICE_MUSIC_HOSTS)): return song_infos
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, playlist_result_first = [], 1, {}
|
||||
while True:
|
||||
request_page_url = playlist_url if page == 1 else f"{playlist_url}?page={page}"
|
||||
try: (resp := self.get(request_page_url, allow_redirects=True, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
(playlist_result := {'name': self._extractplaylistname(resp.text), 'id': playlist_id})['songs'] = self._extractplaylistpagesongs(resp.text, "https://streetvoice.cn")
|
||||
if not playlist_result['songs']: break
|
||||
tracks_in_playlist.extend(playlist_result['songs']); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['name'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of TIDALMusicClient: https://tidal.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
import aigpy
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from .base import BaseMusicClient
|
||||
from pathvalidate import sanitize_filepath
|
||||
from ..utils.hosts import TIDAL_MUSIC_HOSTS
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn
|
||||
from ..utils.tidalutils import TIDALMusicClientUtils, SearchResult, SessionStorage, Track, TidalTvSession, StreamUrl, Artist
|
||||
from ..utils import legalizestring, resp2json, seconds2hms, touchdir, replacefile, usesearchheaderscookies, usedownloadheaderscookies, safeextractfromdict, useparseheaderscookies, hostmatchessuffix, obtainhostname, cleanlrc, SongInfo, SongInfoUtils
|
||||
|
||||
|
||||
'''TIDALMusicClient'''
|
||||
class TIDALMusicClient(BaseMusicClient):
|
||||
source = 'TIDALMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(TIDALMusicClient, self).__init__(**kwargs)
|
||||
assert self.default_search_cookies or self.default_download_cookies or self.default_parse_cookies, f'cookies are not configured, so TIDAL is unavailable, refer to https://musicdl.readthedocs.io/en/latest/Quickstart.html#tidal-high-quality-music-download.'
|
||||
session_storage = SessionStorage(**(self.default_search_cookies or self.default_download_cookies or self.default_parse_cookies))
|
||||
self.tidal_tv_session = TidalTvSession(session_storage.client_id, session_storage.client_secret)
|
||||
self.tidal_tv_session.setstorage(session_storage); TIDALMusicClientUtils.SESSION_STORAGE = session_storage
|
||||
self.default_search_headers = {"X-Tidal-Token": self.tidal_tv_session.client_id, "Authorization": f"Bearer {self.tidal_tv_session.access_token}", "Connection": "Keep-Alive", "Accept-Encoding": "gzip", "User-Agent": "TIDAL_ANDROID/1039 okhttp/3.14.9"}
|
||||
self.default_parse_headers = {"X-Tidal-Token": self.tidal_tv_session.client_id, "Authorization": f"Bearer {self.tidal_tv_session.access_token}", "Connection": "Keep-Alive", "Accept-Encoding": "gzip", "User-Agent": "TIDAL_ANDROID/1039 okhttp/3.14.9"}
|
||||
self.default_download_headers = {"X-Tidal-Token": self.tidal_tv_session.client_id, "Authorization": f"Bearer {self.tidal_tv_session.access_token}", "Connection": "Keep-Alive", "Accept-Encoding": "gzip", "User-Agent": "TIDAL_ANDROID/1039 okhttp/3.14.9"}
|
||||
self.default_headers = self.default_search_headers
|
||||
self.default_search_cookies = {}; self.default_parse_cookies = {}; self.default_download_cookies = {}; self.default_cookies = {}
|
||||
self._initsession()
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
if isinstance(song_info.download_url, str): return super()._download(song_info=song_info, request_overrides=request_overrides, downloaded_song_infos=downloaded_song_infos, progress=progress, song_progress_id=song_progress_id, auto_supplement_song=auto_supplement_song)
|
||||
request_overrides = request_overrides or {}
|
||||
try:
|
||||
touchdir(song_info.work_dir); stream_url: StreamUrl = song_info.download_url; stream_resp: dict = song_info.raw_data['download']
|
||||
download_ext, final_ext = TIDALMusicClientUtils.guessstreamextension(stream=stream_url), f'.{song_info.ext}'
|
||||
remux_required = TIDALMusicClientUtils.shouldremuxflac(download_ext, final_ext, stream_url)
|
||||
assert TIDALMusicClientUtils.flacremuxavailable(), f'FLAC stream for {stream_url.url} requires remuxing but no backend is available.'
|
||||
progress.update(song_progress_id, total=1, kind='overall'); progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Downloading)")
|
||||
with tempfile.TemporaryDirectory(prefix="musicdl-TIDALMusicClient-track-") as tmpdir:
|
||||
download_part = os.path.join(tmpdir, f"download{download_ext}.part" if download_ext else "download.part")
|
||||
if "vnd.tidal.bt" in stream_resp['manifestMimeType']:
|
||||
tool = aigpy.download.DownloadTool(download_part, stream_url.urls); tool.setUserProgress(None); tool.setPartSize(song_info.chunk_size)
|
||||
check, err = tool.start(showProgress=False)
|
||||
if not check: raise RuntimeError(err)
|
||||
elif "dash+xml" in stream_resp['manifestMimeType']:
|
||||
local_file_path, manifest_content = os.path.join(tmpdir, str(song_info.identifier) + '.mpd'), base64.b64decode(stream_resp['manifest'])
|
||||
with open(local_file_path, "wb") as fp: fp.write(manifest_content)
|
||||
check = TIDALMusicClientUtils.downloadstreamwithnm3u8dlre(local_file_path, download_part, silent=self.disable_print, random_uuid=str(song_info.identifier))
|
||||
if not check: raise RuntimeError(f"N_m3u8DL-RE error while dealing with {manifest_content.decode('utf-8')}")
|
||||
download_part = max(Path(download_part).parent.glob(f"{Path(download_part).name}*"), key=lambda p: p.stat().st_mtime, default=None)
|
||||
decrypted_target, remux_target = os.path.join(tmpdir, f"decrypted{download_ext}" if download_ext else "decrypted"), os.path.join(tmpdir, "remux.flac")
|
||||
decrypted_path = TIDALMusicClientUtils.decryptdownloadedaudio(stream_url, download_part, decrypted_target); processed_path = decrypted_path
|
||||
if remux_required:
|
||||
processed_path, backend_used = TIDALMusicClientUtils.remuxflacstream(decrypted_path, remux_target)
|
||||
if processed_path != decrypted_path and os.path.exists(decrypted_path): os.remove(decrypted_path)
|
||||
else: final_ext = download_ext; processed_path = decrypted_path
|
||||
replacefile(processed_path, song_info.save_path)
|
||||
progress.update(song_progress_id, total=os.path.getsize(song_info.save_path), kind='download'); progress.advance(song_progress_id, os.path.getsize(song_info.save_path))
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Success)")
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
return downloaded_song_infos
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
self.tidal_tv_session.refresh(request_overrides=request_overrides); TIDALMusicClientUtils.SESSION_STORAGE = self.tidal_tv_session.getstorage()
|
||||
self.default_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_search_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_parse_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_download_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
# search rules
|
||||
default_rule = {'countryCode': self.tidal_tv_session.country_code, 'limit': 10, 'offset': 0, 'query': keyword, 'types': 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS'}
|
||||
default_rule.update(rule)
|
||||
# construct search urls based on search rules
|
||||
base_url = 'https://api.tidalhifi.com/v1/search?'
|
||||
search_urls, page_size, count = [], self.search_size_per_page, 0
|
||||
while self.search_size_per_source > count:
|
||||
page_rule = copy.deepcopy(default_rule)
|
||||
page_rule['limit'] = page_size
|
||||
page_rule['offset'] = count
|
||||
search_urls.append(base_url + urlencode(page_rule))
|
||||
count += page_size
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: Track, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, Track)) or (not (song_id := search_result.id)): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
for quality in TIDALMusicClientUtils.MUSIC_QUALITIES:
|
||||
try: download_url, stream_resp = TIDALMusicClientUtils.getstreamurl(song_id, quality=quality[1], apply_thirdpart_apis=(not self.tidal_tv_session.isvipaccount(request_overrides=request_overrides)), request_overrides=request_overrides)
|
||||
except Exception: continue
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': stream_resp, 'lyric': {}, 'quality': quality}, source=self.source, song_name=legalizestring(search_result.title), singers=legalizestring(', '.join([str(singer.name) for singer in (search_result.artists or []) if isinstance(singer, Artist)])),
|
||||
album=legalizestring(search_result.album.title), ext=TIDALMusicClientUtils.getexpectedextension(download_url).removeprefix('.'), file_size_bytes='HLS', file_size='HLS', identifier=search_result.id, duration_s=search_result.duration, duration=seconds2hms(search_result.duration), lyric=None,
|
||||
cover_url=TIDALMusicClientUtils.getcoverurl(search_result.album.cover), download_url=download_url, download_url_status=self.audio_link_tester.test(download_url.urls[0], request_overrides),
|
||||
)
|
||||
if song_info.with_valid_download_url: break
|
||||
if not song_info.with_valid_download_url: return song_info
|
||||
# supplement lyric results
|
||||
params = {'countryCode': self.tidal_tv_session.country_code, 'include': 'lyrics'}
|
||||
try: (resp := self.get(f'https://openapi.tidal.com/v2/tracks/{song_id}', params=params, **request_overrides)).raise_for_status(); lyric = cleanlrc(safeextractfromdict((lyric_result := resp2json(resp)), ['included', 0, 'attributes', 'lrcText'], 'NULL'))
|
||||
except Exception: lyric_result, lyric = {}, '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
|
||||
# return
|
||||
return song_info
|
||||
'''_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()
|
||||
for search_result in aigpy.model.dictToModel(resp2json(resp=resp), SearchResult()).tracks.items:
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
'''parseplaylist'''
|
||||
@useparseheaderscookies
|
||||
def parseplaylist(self, playlist_url: str, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides = request_overrides or {}
|
||||
playlist_url = self.session.head(playlist_url, allow_redirects=True, **request_overrides).url
|
||||
playlist_id, song_infos = urlparse(playlist_url).path.strip('/').split('/')[-1].removesuffix('.html').removesuffix('.htm'), []
|
||||
if (not (hostname := obtainhostname(url=playlist_url))) or (not hostmatchessuffix(hostname, TIDAL_MUSIC_HOSTS)): return song_infos
|
||||
self.tidal_tv_session.refresh(request_overrides=request_overrides); TIDALMusicClientUtils.SESSION_STORAGE = self.tidal_tv_session.getstorage()
|
||||
self.default_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_search_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_parse_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
self.default_download_headers.update({"Authorization": f"Bearer {self.tidal_tv_session.access_token}"})
|
||||
# get tracks in playlist
|
||||
tracks_in_playlist, page, page_size, playlist_result_first = [], 1, 50, {}
|
||||
while True:
|
||||
params = {'offset': (page - 1) * page_size, 'limit': page_size, 'countryCode': self.tidal_tv_session.country_code, 'locale': 'en_US', 'deviceType': 'BROWSER'}
|
||||
try: (resp := self.get(f"https://tidal.com/v1/playlists/{playlist_id}/items", params=params, **request_overrides)).raise_for_status()
|
||||
except Exception: break
|
||||
if (not safeextractfromdict((playlist_result := resp2json(resp=resp)), ['items'], [])): break
|
||||
tracks_in_playlist.extend(safeextractfromdict(playlist_result, ['items'], [])); page += 1
|
||||
if not playlist_result_first: playlist_result_first = copy.deepcopy(playlist_result)
|
||||
if (float(safeextractfromdict(playlist_result, ['totalNumberOfItems'], 0)) <= len(tracks_in_playlist)): break
|
||||
for track_idx in range(len(tracks_in_playlist)):
|
||||
try: tracks_in_playlist[track_idx] = aigpy.model.dictToModel(tracks_in_playlist[track_idx]['item'], Track()); assert tracks_in_playlist[track_idx].id
|
||||
except Exception: continue
|
||||
tracks_in_playlist = list({d.id: d for d in tracks_in_playlist}.values())
|
||||
try: playlist_result_first['meta_info'] = resp2json(self.get(f'https://tidal.com/v1/playlists/{playlist_id}?countryCode={self.tidal_tv_session.country_code}&locale=en_US&deviceType=BROWSER', **request_overrides))
|
||||
except Exception: pass
|
||||
# parse track by track in playlist
|
||||
with Progress(TextColumn("{task.description}"), BarColumn(bar_width=None), MofNCompleteColumn(), TimeRemainingColumn(), refresh_per_second=10) as main_process_context:
|
||||
main_progress_id = main_process_context.add_task(f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed (0/{len(tracks_in_playlist)})", total=len(tracks_in_playlist))
|
||||
for idx, track_info in enumerate(tracks_in_playlist):
|
||||
if idx > 0: main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx}/{len(tracks_in_playlist)})")
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=track_info, song_info_flac=None, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
if song_info.with_valid_download_url: song_infos.append(song_info)
|
||||
main_process_context.advance(main_progress_id, 1)
|
||||
main_process_context.update(main_progress_id, description=f"{len(tracks_in_playlist)} songs found in playlist {playlist_id} >>> completed ({idx+1}/{len(tracks_in_playlist)})")
|
||||
# post processing
|
||||
playlist_name = safeextractfromdict(playlist_result_first, ['meta_info', 'title'], None)
|
||||
song_infos = self._removeduplicates(song_infos=song_infos); work_dir = self._constructuniqueworkdir(keyword=legalizestring(playlist_name or f"playlist-{playlist_id}"))
|
||||
for song_info in song_infos:
|
||||
song_info.work_dir = work_dir; episodes = song_info.episodes if isinstance(song_info.episodes, list) else []
|
||||
for eps_info in episodes: eps_info.work_dir = sanitize_filepath(os.path.join(work_dir, song_info.song_name)); touchdir(work_dir)
|
||||
# return results
|
||||
return song_infos
|
||||
@@ -0,0 +1,240 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of YouTubeMusicClient: https://music.youtube.com/
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
import copy
|
||||
import base64
|
||||
import random
|
||||
from ytmusicapi import YTMusic
|
||||
from .base import BaseMusicClient
|
||||
from rich.progress import Progress
|
||||
from ..utils.youtubeutils import YouTube, REPAIDAPI_KEYS
|
||||
from ..utils import legalizestring, resp2json, usesearchheaderscookies, byte2mb, seconds2hms, usedownloadheaderscookies, touchdir, safeextractfromdict, SongInfo, SongInfoUtils, AudioLinkTester, LyricSearchClient
|
||||
|
||||
|
||||
'''YouTubeMusicClient'''
|
||||
class YouTubeMusicClient(BaseMusicClient):
|
||||
source = 'YouTubeMusicClient'
|
||||
def __init__(self, **kwargs):
|
||||
super(YouTubeMusicClient, 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"}
|
||||
self.default_download_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"}
|
||||
self.default_headers = self.default_search_headers
|
||||
self._initsession()
|
||||
'''_download'''
|
||||
@usedownloadheaderscookies
|
||||
def _download(self, song_info: SongInfo, request_overrides: dict = None, downloaded_song_infos: list = [], progress: Progress = None, song_progress_id: int = 0, auto_supplement_song: bool = True):
|
||||
if isinstance(song_info.download_url, str): return super()._download(song_info=song_info, request_overrides=request_overrides, downloaded_song_infos=downloaded_song_infos, progress=progress, song_progress_id=song_progress_id, auto_supplement_song=auto_supplement_song)
|
||||
request_overrides = request_overrides or {}
|
||||
try:
|
||||
touchdir(song_info.work_dir)
|
||||
total_size, chunk_size, downloaded_size = int(song_info.download_url.filesize), song_info.get('chunk_size', 1024 * 1024), 0
|
||||
progress.update(song_progress_id, total=total_size)
|
||||
with open(song_info.save_path, "wb") as fp:
|
||||
for chunk in song_info.download_url.iterchunks(chunk_size=chunk_size):
|
||||
if not chunk: continue
|
||||
fp.write(chunk); downloaded_size = downloaded_size + len(chunk)
|
||||
if total_size > 0: downloading_text = "%0.2fMB/%0.2fMB" % (downloaded_size / 1024 / 1024, total_size / 1024 / 1024)
|
||||
else: progress.update(song_progress_id, total=downloaded_size); downloading_text = "%0.2fMB/%0.2fMB" % (downloaded_size / 1024 / 1024, downloaded_size / 1024 / 1024)
|
||||
progress.advance(song_progress_id, len(chunk))
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Downloading: {downloading_text})")
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Success)")
|
||||
downloaded_song_infos.append(SongInfoUtils.supplsonginfothensavelyricsthenwritetags(copy.deepcopy(song_info), logger_handle=self.logger_handle, disable_print=self.disable_print) if auto_supplement_song else copy.deepcopy(song_info))
|
||||
except Exception as err:
|
||||
progress.update(song_progress_id, description=f"{self.source}.download >>> {song_info.song_name[:10] + '...' if len(song_info.song_name) > 13 else song_info.song_name[:13]} (Error: {err})")
|
||||
return downloaded_song_infos
|
||||
'''_constructsearchurls'''
|
||||
def _constructsearchurls(self, keyword: str, rule: dict = None, request_overrides: dict = None):
|
||||
# init
|
||||
rule, request_overrides = rule or {}, request_overrides or {}
|
||||
decrypt_func = lambda t: base64.b64decode(str(t).encode('utf-8')).decode('utf-8')
|
||||
# adapt ytmusicapi to conduct music file search
|
||||
ytmusic_search_api = YTMusic(auth=rule.get('auth', None), user=rule.get('user', None), requests_session=None, proxies=request_overrides.get('proxies', None) or self._autosetproxies(), language=rule.get('language', 'en'), location=rule.get('location', ''), oauth_credentials=rule.get('oauth_credentials', '')).search
|
||||
ytmusic_search_rule = {'query': keyword, 'filter': rule.get('filter', None), 'scope': rule.get('scope', None), 'limit': self.search_size_per_source, 'ignore_spelling': rule.get('ignore_spelling', False)}
|
||||
# adapt rapidapi to conduct music file search
|
||||
rapidapi_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-Rapidapi-Host": "youtube-music-api3.p.rapidapi.com", "X-Rapidapi-Key": decrypt_func(random.choice(REPAIDAPI_KEYS)),
|
||||
"Referer": "https://music-download-lake.vercel.app/", "Origin": "https://music-download-lake.vercel.app", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
rapidapi_params = {'q': keyword, 'type': 'song', 'limit': self.search_size_per_source}
|
||||
rapidapi_search_rule = {'headers': rapidapi_headers, 'params': rapidapi_params, 'url': 'https://youtube-music-api3.p.rapidapi.com/search'}
|
||||
# construct search urls
|
||||
search_urls = [{'candidate_apis': [{'api': self.get, 'inputs': rapidapi_search_rule, 'method': 'rapidapi'}, {'api': ytmusic_search_api, 'inputs': ytmusic_search_rule, 'method': 'ytmusicapi'}]}]
|
||||
self.search_size_per_page = self.search_size_per_source
|
||||
# return
|
||||
return search_urls
|
||||
'''_parsewithyt1s'''
|
||||
def _parsewithyt1s(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, song_info, MUSIC_QUALITIES = request_overrides or {}, search_result['videoId'], SongInfo(source=self.source), ['320', '256', '128', '96'][:2]
|
||||
transform_search_duration_func = lambda d: "{:02}:{:02}:{:02}".format(*([0] * (3 - len(str(d).split(":"))) + list(map(int, str(d).split(":")))))
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
try: (resp := self.post('https://embed.dlsrv.online/api/download/mp3', json={"videoId": song_id, "format": "mp3", "quality": quality}, headers={"Content-Type": "application/json", "Origin": "https://embed.dlsrv.online", "Accept": "*/*"}, timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url: str = (download_result := resp2json(resp=resp)).get('url')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.get(download_url, allow_redirects=True, **request_overrides)).raise_for_status()
|
||||
except Exception: 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('author') or (', '.join([singer.get('name') for singer in (search_result.get('artists') or []) if isinstance(singer, dict) and singer.get('name')]))), album=legalizestring(search_result.get('album')),
|
||||
ext='mp3', file_size_bytes=resp.content.__sizeof__(), file_size=byte2mb(resp.content.__sizeof__()), identifier=song_id, duration_s=search_result.get('duration_seconds', 0) or 0, duration=transform_search_duration_func(search_result.get('duration', '0:00') or '0:00'), lyric='NULL', cover_url=search_result.get('thumbnail') or safeextractfromdict(search_result, ['thumbnails', -1, 'url'], None),
|
||||
download_url=download_url, download_url_status={'ok': True}, downloaded_contents=resp.content, default_download_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"},
|
||||
)
|
||||
if song_info.file_size_bytes < 100: song_info.download_url_status = {'ok': False}
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithmp3youtube'''
|
||||
def _parsvidewithmp3youtube(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, song_info, MUSIC_QUALITIES = request_overrides or {}, search_result['videoId'], SongInfo(source=self.source), ['320', '256', '128', '96'][:2]
|
||||
transform_search_duration_func = lambda d: "{:02}:{:02}:{:02}".format(*([0] * (3 - len(str(d).split(":"))) + list(map(int, str(d).split(":")))))
|
||||
(resp := self.get('https://api.mp3youtube.cc/v2/sanity/key', headers={"Content-Type": "application/json", "Origin": "https://iframe.y2meta-uk.com", "Accept": "*/*"}, timeout=10, **request_overrides)).raise_for_status()
|
||||
mp3youtube_request_key = resp2json(resp)['key']
|
||||
# parse
|
||||
for quality in MUSIC_QUALITIES:
|
||||
audio_payload = {"link": f"https://youtu.be/{song_id}", "format": "mp3", "audioBitrate": quality, "videoQuality": "720", "vCodec": "h264"}
|
||||
try: (resp := self.post('https://api.mp3youtube.cc/v2/converter', json=audio_payload, headers={"Content-Type": "application/json", "Origin": "https://iframe.y2meta-uk.com", "Accept": "*/*", "key": mp3youtube_request_key}, timeout=10, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
download_url: str = (download_result := resp2json(resp=resp)).get('url')
|
||||
if not download_url or not str(download_url).startswith('http'): continue
|
||||
try: (resp := self.get(download_url, allow_redirects=True, **request_overrides)).raise_for_status()
|
||||
except Exception: 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('author') or (', '.join([singer.get('name') for singer in (search_result.get('artists') or []) if isinstance(singer, dict) and singer.get('name')]))), album=legalizestring(search_result.get('album')),
|
||||
ext='mp3', file_size_bytes=resp.content.__sizeof__(), file_size=byte2mb(resp.content.__sizeof__()), identifier=song_id, duration_s=search_result.get('duration_seconds', 0) or 0, duration=transform_search_duration_func(search_result.get('duration', '0:00') or '0:00'), lyric='NULL', cover_url=search_result.get('thumbnail') or safeextractfromdict(search_result, ['thumbnails', -1, 'url'], None),
|
||||
download_url=download_url, download_url_status={'ok': True}, downloaded_contents=resp.content, default_download_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"},
|
||||
)
|
||||
if song_info.file_size_bytes < 100: song_info.download_url_status = {'ok': False}
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithclipto'''
|
||||
def _parsewithclipto(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result['videoId'], SongInfo(source=self.source)
|
||||
transform_search_duration_func = lambda d: "{:02}:{:02}:{:02}".format(*([0] * (3 - len(str(d).split(":"))) + list(map(int, str(d).split(":")))))
|
||||
# parse
|
||||
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", "content-type": "application/json", "origin": "https://www.clipto.com", "referer": "https://www.clipto.com/media-downloader/"}
|
||||
(resp := self.post('https://www.clipto.com/api/youtube', json={"url": f"https://www.youtube.com/watch?v={song_id}"}, headers=headers, **request_overrides)).raise_for_status()
|
||||
download_result = resp2json(resp=resp)
|
||||
medias = [dr for dr in download_result['medias'] if isinstance(dr, dict) and (dr.get('type') in ('audio',) or 'audio' in dr.get('mimeType'))]
|
||||
medias = sorted(medias, key=lambda x: int(float(x.get('contentLength', 0) or 0)), reverse=True)
|
||||
for media in medias:
|
||||
download_url: str = media.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('author') or (', '.join([singer.get('name') for singer in (search_result.get('artists') or []) if isinstance(singer, dict) and singer.get('name')]))),
|
||||
album=legalizestring(search_result.get('album')), ext=media.get('extension', 'm4a') or 'm4a', file_size_bytes=int(float(media.get('contentLength', 0) or 0)), file_size=byte2mb(int(float(media.get('contentLength', 0) or 0))), identifier=song_id, duration_s=download_result.get('duration'), duration=seconds2hms(download_result.get('duration')),
|
||||
lyric='NULL', cover_url=search_result.get('thumbnail') or safeextractfromdict(search_result, ['thumbnails', -1, 'url'], 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.ext in {'mp4', 'm4a', 'weba'}: 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'
|
||||
if not song_info.duration or song_info.duration == '-:-:-': transform_search_duration_func(search_result.get('duration', '0:00') or '0:00')
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewithacethinker'''
|
||||
def _parsewithacethinker(self, search_result: dict, request_overrides: dict = None):
|
||||
# init
|
||||
request_overrides, song_id, song_info = request_overrides or {}, search_result['videoId'], SongInfo(source=self.source)
|
||||
transform_search_duration_func = lambda d: "{:02}:{:02}:{:02}".format(*([0] * (3 - len(str(d).split(":"))) + list(map(int, str(d).split(":")))))
|
||||
(resp := self.get('https://www.acethinker.ai/downloader/api/get_csrf_token.php', **request_overrides)).raise_for_status()
|
||||
# parse
|
||||
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", "accept": "application/json, text/plain, */*", "referer": "https://www.acethinker.ai/freemp3finder", "x-csrf-token": resp2json(resp=resp)['token']}
|
||||
(resp := self.get(f'https://www.acethinker.ai/downloader/api/dlapinewv2.php?url=https://www.youtube.com/watch?v={song_id}', headers=headers, **request_overrides)).raise_for_status()
|
||||
download_result: dict = resp2json(resp=resp)['res_data']
|
||||
medias = [a for a in download_result['formats'] if isinstance(a, dict) and str(a.get('vcodec')).lower() in {"", "none"}]
|
||||
medias = sorted(medias, key=lambda x: int(float(x.get('filesize', 0) or 0)), reverse=True)
|
||||
for media in medias:
|
||||
if not (download_url := media.get('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('author') or (', '.join([singer.get('name') for singer in (search_result.get('artists') or []) if isinstance(singer, dict) and singer.get('name')]))),
|
||||
album=legalizestring(search_result.get('album')), ext=media.get('ext', 'm4a') or 'm4a', file_size_bytes=int(float(media.get('filesize', 0) or 0)), file_size=byte2mb(int(float(media.get('filesize', 0) or 0))), identifier=song_id, duration_s=download_result.get('duration', 0) or 0, duration=seconds2hms(download_result.get('duration', 0) or 0),
|
||||
lyric='NULL', cover_url=search_result.get('thumbnail') or safeextractfromdict(search_result, ['thumbnails', -1, 'url'], 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.ext in {'mp4', 'm4a', 'weba'}: 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'
|
||||
if not song_info.duration or song_info.duration == '-:-:-': transform_search_duration_func(search_result.get('duration', '0:00') or '0:00')
|
||||
if song_info.with_valid_download_url: break
|
||||
try: (resp := self.get(f'https://www.acethinker.ai/downloader/api/newytdlapi/youtube_mp3_audio_video_downloader.php?url=https://www.youtube.com/watch?v={song_id}', headers=headers, **request_overrides)).raise_for_status()
|
||||
except Exception: continue
|
||||
if not (parsed_in_no_us_area := resp2json(resp=resp)).get('download_url'): continue
|
||||
song_info.update(dict(download_url=parsed_in_no_us_area.get('download_url'), download_url_status=self.audio_link_tester.test(parsed_in_no_us_area.get('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']
|
||||
if song_info.with_valid_download_url: break
|
||||
# return
|
||||
return song_info
|
||||
'''_parsewiththirdpartapis'''
|
||||
def _parsewiththirdpartapis(self, search_result: dict, request_overrides: dict = None):
|
||||
if self.default_cookies or request_overrides.get('cookies'): return SongInfo(source=self.source)
|
||||
for imp_func in [self._parsewithyt1s, self._parsvidewithmp3youtube, self._parsewithacethinker, self._parsewithclipto]:
|
||||
try: song_info_flac = imp_func(search_result, request_overrides); assert song_info_flac.with_valid_download_url; break
|
||||
except: song_info_flac = SongInfo(source=self.source)
|
||||
return song_info_flac
|
||||
'''_parsewithofficialapiv1'''
|
||||
def _parsewithofficialapiv1(self, search_result: dict, song_info_flac: SongInfo = None, lossless_quality_is_sufficient: bool = True, lossless_quality_definitions: set | list | tuple = {'flac'}, request_overrides: dict = None) -> "SongInfo":
|
||||
# init
|
||||
song_info, request_overrides, song_info_flac = SongInfo(source=self.source), request_overrides or {}, song_info_flac or SongInfo(source=self.source)
|
||||
if (not isinstance(search_result, dict)) or (not (song_id := search_result.get('videoId'))): return song_info
|
||||
# obtain basic song_info
|
||||
if lossless_quality_is_sufficient and song_info_flac.with_valid_download_url and (song_info_flac.ext in lossless_quality_definitions): song_info = song_info_flac
|
||||
else:
|
||||
download_url = (cli := YouTube(video_id=search_result['videoId'])).streams.getaudioonly()
|
||||
duration_in_secs = (float(download_url.durationMs) / 1000) or search_result.get('duration_seconds', 0) or 0
|
||||
song_info = SongInfo(
|
||||
raw_data={'search': search_result, 'download': cli.vid_info, 'lyric': {}}, source=self.source, song_name=legalizestring(search_result.get('title')), singers=legalizestring(search_result.get('author') or (', '.join([singer.get('name') for singer in (search_result.get('artists') or []) if isinstance(singer, dict) and singer.get('name')]))), album=legalizestring(search_result.get('album')),
|
||||
ext='mp3', file_size_bytes=download_url.filesize, file_size=byte2mb(download_url.filesize), identifier=song_id, duration_s=duration_in_secs, duration=seconds2hms(duration_in_secs), lyric='NULL', cover_url=search_result.get('thumbnail') or safeextractfromdict(search_result, ['thumbnails', -1, 'url'], None), download_url=download_url, download_url_status={'ok': True},
|
||||
)
|
||||
if song_info.file_size_bytes < 100: song_info.download_url_status = {'ok': False}
|
||||
# compare and select the best
|
||||
song_info = song_info_flac if song_info_flac.with_valid_download_url and (not song_info.with_valid_download_url or song_info_flac.largerthan(song_info)) else song_info
|
||||
# supplement lyric results
|
||||
lyric_result, lyric = LyricSearchClient().search(artist_name=song_info.singers, track_name=song_info.song_name, request_overrides=request_overrides)
|
||||
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
|
||||
# return
|
||||
return song_info
|
||||
'''_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 {}
|
||||
candidate_apis = copy.deepcopy(search_url)['candidate_apis']
|
||||
# successful
|
||||
try:
|
||||
# --search results
|
||||
for candidate_api in candidate_apis[1:]:
|
||||
try: resp = candidate_api['api'](**candidate_api['inputs']); candidate_api['method'] in ('rapidapi',) and resp.raise_for_status(); search_results = resp2json(resp=resp)['result'] if candidate_api['method'] in ('rapidapi',) else [s for s in resp if s['resultType'] == 'song'] if candidate_api['method'] in ('ytmusicapi',) else (_ for _ in ()).throw(ValueError(f"Unsupported method: {candidate_api['method']}")); assert len(search_results) > 0; break
|
||||
except Exception: continue
|
||||
for search_result in search_results:
|
||||
# --parse with third part apis
|
||||
song_info_flac = self._parsewiththirdpartapis(search_result=search_result, request_overrides=request_overrides)
|
||||
# --parse with official apis
|
||||
try: song_info = self._parsewithofficialapiv1(search_result=search_result, song_info_flac=song_info_flac, lossless_quality_is_sufficient=False, request_overrides=request_overrides)
|
||||
except Exception: song_info = SongInfo(source=self.source)
|
||||
# --append to song_infos
|
||||
if not song_info.with_valid_download_url: song_info = song_info_flac
|
||||
if not song_info.with_valid_download_url: continue
|
||||
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
|
||||
Reference in New Issue
Block a user