Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
'''
|
||||
Function:
|
||||
Implementation of QQMusicClient Utils
|
||||
Author:
|
||||
Zhenchao Jin
|
||||
WeChat Official Account (微信公众号):
|
||||
Charles的皮卡丘
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import orjson
|
||||
import base64
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
import requests
|
||||
import binascii
|
||||
from enum import Enum
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import ClassVar, TypedDict, Any, cast
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
'''settings'''
|
||||
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDEIxgwoutfwoJxcGQeedgP7FG9qaIuS0qzfR8gWkrkTZKM2iWHn2ajQpBRZjMSoSf6+KJGvar2ORhBfpDXyVtZCKpqLQ+FLkpncClKVIrBwv6PHyUvuCb0rIarmgDnzkfQAqVufEtR64iazGDKatvJ9y6B9NMbHddGSAUmRTCrHQIDAQAB
|
||||
-----END PUBLIC KEY-----"""
|
||||
SECRET = "ZdJqM15EeO2zWc08"
|
||||
APP_KEY = "0AND0HD6FE4HY80F"
|
||||
|
||||
|
||||
'''SongFileType'''
|
||||
class SongFileType(Enum):
|
||||
MASTER = ("AI00", ".flac")
|
||||
ATMOS_2 = ("Q000", ".flac")
|
||||
ATMOS_51 = ("Q001", ".flac")
|
||||
FLAC = ("F000", ".flac")
|
||||
OGG_640 = ("O801", ".ogg")
|
||||
OGG_320 = ("O800", ".ogg")
|
||||
OGG_192 = ("O600", ".ogg")
|
||||
OGG_96 = ("O400", ".ogg")
|
||||
MP3_320 = ("M800", ".mp3")
|
||||
MP3_128 = ("M500", ".mp3")
|
||||
ACC_192 = ("C600", ".m4a")
|
||||
ACC_96 = ("C400", ".m4a")
|
||||
ACC_48 = ("C200", ".m4a")
|
||||
SORTED_QUALITIES = [
|
||||
("AI00", ".flac"), ("Q000", ".flac"), ("Q001", ".flac"), ("F000", ".flac"), ("O801", ".ogg"), ("O800", ".ogg"), ("O600", ".ogg"), ("O400", ".ogg"),
|
||||
("M800", ".mp3"), ("M500", ".mp3"), ("C600", ".m4a"), ("C400", ".m4a"), ("C200", ".m4a")
|
||||
]
|
||||
|
||||
|
||||
'''EncryptedSongFileType'''
|
||||
class EncryptedSongFileType(Enum):
|
||||
MASTER = ("AIM0", ".mflac")
|
||||
ATMOS_2 = ("Q0M0", ".mflac")
|
||||
ATMOS_51 = ("Q0M1", ".mflac")
|
||||
FLAC = ("F0M0", ".mflac")
|
||||
OGG_640 = ("O801", ".mgg")
|
||||
OGG_320 = ("O800", ".mgg")
|
||||
OGG_192 = ("O6M0", ".mgg")
|
||||
OGG_96 = ("O4M0", ".mgg")
|
||||
SORTED_QUALITIES = [
|
||||
("AIM0", ".mflac"), ("Q0M0", ".mflac"), ("Q0M1", ".mflac"), ("F0M0", ".mflac"), ("O801", ".mgg"), ("O800", ".mgg"), ("O6M0", ".mgg"), ("O4M0", ".mgg")
|
||||
]
|
||||
|
||||
|
||||
'''ThirdPartVKeysAPISongFileType'''
|
||||
class ThirdPartVKeysAPISongFileType(Enum):
|
||||
TRIAL_LISTEN = (0,)
|
||||
LOSSY_QUALITY = (1, 2, 3)
|
||||
STANDARD_QUALITY = (4, 5, 6, 7)
|
||||
HQ_QUALITY = (8,)
|
||||
HQ_QUALITY_ENHANCED = (9,)
|
||||
SQ_LOSSLESS_QUALITY = (10,)
|
||||
HI_RES_QUALITY = (11,)
|
||||
DOLBY_ATMOS = (12,)
|
||||
PREMIUM_SPATIAL_AUDIO = (13,)
|
||||
PREMIUM_MASTER_2_0 = (14,)
|
||||
AI_ACCOMPANIMENT_MODE_4TRACK = (15,)
|
||||
AI_5_1_QUALITY_6TRACK = (16,)
|
||||
ID_TO_NAME = {
|
||||
0: "TRIAL_LISTEN", 1: "LOSSY_QUALITY", 2: "LOSSY_QUALITY", 3: "LOSSY_QUALITY", 4: "STANDARD_QUALITY", 5: "STANDARD_QUALITY", 6: "STANDARD_QUALITY", 7: "STANDARD_QUALITY",
|
||||
8: "HQ_QUALITY", 9: "HQ_QUALITY_ENHANCED", 10: "SQ_LOSSLESS_QUALITY", 11: "HI_RES_QUALITY", 12: "DOLBY_ATMOS", 13: "PREMIUM_SPATIAL_AUDIO", 14: "PREMIUM_MASTER_2_0",
|
||||
15: "AI_ACCOMPANIMENT_MODE_4TRACK", 16: "AI_5_1_QUALITY_6TRACK",
|
||||
}
|
||||
|
||||
|
||||
'''SearchType'''
|
||||
class SearchType(Enum):
|
||||
SONG = 0
|
||||
SINGER = 1
|
||||
ALBUM = 2
|
||||
SONGLIST = 3
|
||||
MV = 4
|
||||
LYRIC = 7
|
||||
USER = 8
|
||||
AUDIO_ALBUM = 15
|
||||
AUDIO = 18
|
||||
|
||||
|
||||
'''QimeiResult'''
|
||||
class QimeiResult(TypedDict):
|
||||
q16: str
|
||||
q36: str
|
||||
|
||||
|
||||
'''OSVersion'''
|
||||
@dataclass
|
||||
class OSVersion:
|
||||
incremental: str = "5891938"
|
||||
release: str = "10"
|
||||
codename: str = "REL"
|
||||
sdk: int = 29
|
||||
|
||||
|
||||
'''Device'''
|
||||
@dataclass
|
||||
class Device:
|
||||
display: str = field(default_factory=lambda: f"QMAPI.{random.randint(100000, 999999)}.001")
|
||||
product: str = "iarim"
|
||||
device: str = "sagit"
|
||||
board: str = "eomam"
|
||||
model: str = "MI 6"
|
||||
fingerprint: str = field(default_factory=lambda: f"xiaomi/iarim/sagit:10/eomam.200122.001/{random.randint(1000000, 9999999)}:user/release-keys")
|
||||
boot_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
proc_version: str = field(default_factory=lambda: f"Linux 5.4.0-54-generic-{''.join(random.choices(string.ascii_letters + string.digits, k=8))} (android-build@google.com)")
|
||||
imei: str = field(default_factory=lambda: (lambda d: "".join(map(str, d)) + str(sum((x * 2 // 10 + x * 2 % 10) if i % 2 == 0 else x for i, x in enumerate(d)) * 9 % 10))([random.randint(0, 9) for _ in range(14)]))
|
||||
brand: str = "Xiaomi"
|
||||
bootloader: str = "U-boot"
|
||||
base_band: str = ""
|
||||
version: OSVersion = field(default_factory=OSVersion)
|
||||
sim_info: str = "T-Mobile"
|
||||
os_type: str = "android"
|
||||
mac_address: str = "00:50:56:C0:00:08"
|
||||
ip_address: ClassVar[list[int]] = [10, 0, 1, 3]
|
||||
wifi_bssid: str = "00:50:56:C0:00:08"
|
||||
wifi_ssid: str = "<unknown ssid>"
|
||||
imsi_md5: list[int] = field(default_factory=lambda: list(hashlib.md5(bytes([random.randint(0, 255) for _ in range(16)])).digest()))
|
||||
android_id: str = field(default_factory=lambda: binascii.hexlify(bytes([random.randint(0, 255) for _ in range(8)])).decode("utf-8"))
|
||||
apn: str = "wifi"
|
||||
vendor_name: str = "MIUI"
|
||||
vendor_os_name: str = "qmapi"
|
||||
qimei: None | str = None
|
||||
|
||||
|
||||
'''Credential'''
|
||||
@dataclass
|
||||
class Credential:
|
||||
openid: str = ""
|
||||
refresh_token: str = ""
|
||||
access_token: str = ""
|
||||
expired_at: int = 0
|
||||
musicid: int = 0
|
||||
musickey: str = ""
|
||||
unionid: str = ""
|
||||
str_musicid: str = ""
|
||||
refresh_key: str = ""
|
||||
encrypt_uin: str = ""
|
||||
login_type: int = 0
|
||||
extra_fields: dict[str, Any] = field(default_factory=dict)
|
||||
'''postinit'''
|
||||
def __post_init__(self):
|
||||
if not self.login_type: self.login_type = 1 if self.musickey and self.musickey.startswith("W_X") else 2
|
||||
'''todict'''
|
||||
def todict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["loginType"], d["encryptUin"] = d.pop("login_type"), d.pop("encrypt_uin")
|
||||
return d
|
||||
'''asjson'''
|
||||
def asjson(self) -> str:
|
||||
data = self.todict()
|
||||
data.update(data.pop("extra_fields"))
|
||||
return orjson.dumps(data).decode()
|
||||
'''fromcookiesdict'''
|
||||
@classmethod
|
||||
def fromcookiesdict(cls, cookies: dict[str, Any]):
|
||||
return cls(
|
||||
openid=cookies.get("openid") or cookies.get("psrf_qqopenid") or cookies.get("wxopenid"), refresh_token=cookies.get("refresh_token") or cookies.get("psrf_qqrefresh_token") or cookies.get("wxrefresh_token"),
|
||||
access_token=cookies.get("access_token") or cookies.get("psrf_qqaccess_token") or cookies.get("wxaccess_token"), expired_at=cookies.get("expired_at") or cookies.get("psrf_access_token_expiresAt"), extra_fields=cookies,
|
||||
musicid=int(cookies.get("musicid", 0) or cookies.get("uin", 0)), musickey=cookies.get("musickey") or cookies.get("qqmusic_key"), unionid=cookies.get("unionid") or cookies.get("psrf_qqunionid") or cookies.get("wxunionid"),
|
||||
str_musicid=cookies.get("str_musicid") or cookies.get("musicid") or cookies.get("uin"), refresh_key=cookies.get("refresh_key"), encrypt_uin=cookies.get("encryptUin"), login_type=cookies.get("loginType") or cookies.get("tmeLoginType"),
|
||||
)
|
||||
|
||||
|
||||
'''QQMusicClientUtils'''
|
||||
class QQMusicClientUtils(object):
|
||||
version, version_code, qimei_result, device = "13.2.5.8", 13020508, {}, Device()
|
||||
endpoint = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
enc_endpoint = "https://u.y.qq.com/cgi-bin/musics.fcg"
|
||||
music_domain = "https://isure.stream.qqmusic.qq.com/"
|
||||
COMMON_DEFAULTS: ClassVar[dict[str, str]] = {"ct": "11", "tmeAppID": "qqmusic", "format": "json", "inCharset": "utf-8", "outCharset": "utf-8", "uid": "3931641530"}
|
||||
@property
|
||||
def qimei(self) -> QimeiResult:
|
||||
if self.qimei_result: return self.qimei_result
|
||||
self.qimei_result = QQMusicClientUtils.obtainqimei(version=QQMusicClientUtils.version, device=QQMusicClientUtils.device)
|
||||
return self.qimei_result
|
||||
'''rsaencrypt'''
|
||||
@staticmethod
|
||||
def rsaencrypt(content: bytes):
|
||||
key = cast(RSAPublicKey, serialization.load_pem_public_key(PUBLIC_KEY.encode()))
|
||||
return key.encrypt(content, padding.PKCS1v15())
|
||||
'''aesencrypt'''
|
||||
@staticmethod
|
||||
def aesencrypt(key: bytes, content: bytes):
|
||||
cipher = Cipher(algorithms.AES(key), modes.CBC(key))
|
||||
padding_size = 16 - len(content) % 16
|
||||
encryptor = cipher.encryptor()
|
||||
return encryptor.update(content + (padding_size * chr(padding_size)).encode()) + encryptor.finalize()
|
||||
'''calcmd5'''
|
||||
@staticmethod
|
||||
def calcmd5(*strings: str | bytes):
|
||||
md5 = hashlib.md5()
|
||||
for item in strings:
|
||||
assert isinstance(item, (str, bytes))
|
||||
if isinstance(item, bytes): md5.update(item)
|
||||
elif isinstance(item, str): md5.update(item.encode())
|
||||
return md5.hexdigest()
|
||||
'''hash33'''
|
||||
@staticmethod
|
||||
def hash33(s: str, h: int = 0) -> int:
|
||||
for c in s: h = (h << 5) + h + ord(c)
|
||||
return 2147483647 & h
|
||||
'''sign'''
|
||||
@staticmethod
|
||||
def sign(request: dict) -> str:
|
||||
PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
|
||||
PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
|
||||
SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
|
||||
PART_1_INDEXES = filter(lambda x: x < 40, PART_1_INDEXES)
|
||||
hash = hashlib.sha1(orjson.dumps(request)).hexdigest().upper()
|
||||
part1, part2, part3 = "".join(hash[i] for i in PART_1_INDEXES), "".join(hash[i] for i in PART_2_INDEXES), bytearray(20)
|
||||
for i, v in enumerate(SCRAMBLE_VALUES): part3[i] = v ^ int(hash[i * 2 : i * 2 + 2], 16)
|
||||
b64_part = re.sub(rb"[\\/+=]", b"", base64.b64encode(part3)).decode("utf-8")
|
||||
return f"zzc{part1}{b64_part}{part2}".lower()
|
||||
'''randombeaconid'''
|
||||
@staticmethod
|
||||
def randombeaconid():
|
||||
beacon_id, time_month, rand1, rand2 = "", datetime.now().strftime("%Y-%m-") + "01", random.randint(100000, 999999), random.randint(100000000, 999999999)
|
||||
for i in range(1, 41):
|
||||
if i in [1, 2, 13, 14, 17, 18, 21, 22, 25, 26, 29, 30, 33, 34, 37, 38]: beacon_id += f"k{i}:{time_month}{rand1}.{rand2}"
|
||||
elif i == 3: beacon_id += "k3:0000000000000000"
|
||||
elif i == 4: beacon_id += f"k4:{''.join(random.choices('123456789abcdef', k=16))}"
|
||||
else: beacon_id += f"k{i}:{random.randint(0, 9999)}"
|
||||
beacon_id += ";"
|
||||
return beacon_id
|
||||
'''randompayloadbydevice'''
|
||||
@staticmethod
|
||||
def randompayloadbydevice(device: Device, version: str):
|
||||
fixed_rand = random.randint(0, 14400)
|
||||
reserved = {
|
||||
"harmony": "0", "clone": "0", "containe": "", "oz": "UhYmelwouA+V2nPWbOvLTgN2/m8jwGB+yUB5v9tysQg=", "oo": "Xecjt+9S1+f8Pz2VLSxgpw==",
|
||||
"kelong": "0", "uptimes": (datetime.now() - timedelta(seconds=fixed_rand)).strftime("%Y-%m-%d %H:%M:%S"), "multiUser": "0", "bod": device.brand,
|
||||
"dv": device.device, "firstLevel": "", "manufact": device.brand, "name": device.model, "host": "se.infra", "kernel": device.proc_version,
|
||||
}
|
||||
return {
|
||||
"androidId": device.android_id, "platformId": 1, "appKey": APP_KEY, "appVersion": version, "beaconIdSrc": QQMusicClientUtils.randombeaconid(),
|
||||
"brand": device.brand, "channelId": "10003505", "cid": "", "imei": device.imei, "imsi": "", "mac": "", "model": device.model, "networkType": "unknown",
|
||||
"oaid": "", "osVersion": f"Android {device.version.release},level {device.version.sdk}", "qimei": "", "qimei36": "", "sdkVersion": "1.2.13.6",
|
||||
"targetSdkVersion": "33", "audit": "", "userId": "{}", "packageId": "com.tencent.qqmusic", "deviceType": "Phone", "sdkName": "", "reserved": orjson.dumps(reserved).decode(),
|
||||
}
|
||||
'''obtainqimei'''
|
||||
@staticmethod
|
||||
def obtainqimei(version: str, device: Device):
|
||||
try:
|
||||
payload, ts = QQMusicClientUtils.randompayloadbydevice(device, version), int(time.time())
|
||||
crypt_key, nonce = "".join(random.choices("adbcdef1234567890", k=16)), "".join(random.choices("adbcdef1234567890", k=16))
|
||||
key = base64.b64encode(QQMusicClientUtils.rsaencrypt(crypt_key.encode())).decode()
|
||||
params = base64.b64encode(QQMusicClientUtils.aesencrypt(crypt_key.encode(), orjson.dumps(payload))).decode()
|
||||
extra = '{"appKey":"' + APP_KEY + '"}'
|
||||
sign = QQMusicClientUtils.calcmd5(key, params, str(ts * 1000), nonce, SECRET, extra)
|
||||
resp = requests.post("https://api.tencentmusic.com/tme/trpc/proxy",
|
||||
headers={
|
||||
"Host": "api.tencentmusic.com", "method": "GetQimei", "service": "trpc.tme_datasvr.qimeiproxy.QimeiProxy", "appid": "qimei_qq_android",
|
||||
"sign": QQMusicClientUtils.calcmd5("qimei_qq_androidpzAuCmaFAaFaHrdakPjLIEqKrGnSOOvH", str(ts)), "user-agent": "QQMusic", "timestamp": str(ts),
|
||||
},
|
||||
json={"app": 0, "os": 1, "qimeiParams": {"key": key, "params": params, "time": str(ts), "nonce": nonce, "sign": sign, "extra": extra}},
|
||||
)
|
||||
data = orjson.loads(orjson.loads(resp.content)["data"])["data"]
|
||||
device.qimei = data["q36"]
|
||||
return QimeiResult(q16=data["q16"], q36=data["q36"])
|
||||
except:
|
||||
result = QimeiResult(q16="", q36="6c9d3cd110abca9b16311cee10001e717614")
|
||||
return result
|
||||
'''randomguid'''
|
||||
@staticmethod
|
||||
def randomguid():
|
||||
return "".join(random.choices("abcdef1234567890", k=32))
|
||||
'''randomsearchid'''
|
||||
@staticmethod
|
||||
def randomsearchid():
|
||||
e = random.randint(1, 20)
|
||||
t = e * 18014398509481984
|
||||
n = random.randint(0, 4194304) * 4294967296
|
||||
a = time.time()
|
||||
r = round(a * 1000) % (24 * 60 * 60 * 1000)
|
||||
return str(t + n + r)
|
||||
'''buildcommonparams'''
|
||||
@staticmethod
|
||||
def buildcommonparams(credential: Credential = None, common_override: dict = None) -> dict[str, Any]:
|
||||
common_override, credential = common_override or {}, credential or Credential()
|
||||
qimei_result = QQMusicClientUtils().qimei
|
||||
common = {"cv": QQMusicClientUtils.version_code, "v": QQMusicClientUtils.version_code, "QIMEI36": qimei_result['q36']}
|
||||
common.update(QQMusicClientUtils.COMMON_DEFAULTS)
|
||||
if bool(credential.musicid) and bool(credential.musickey): common.update({"qq": str(credential.musicid), "authst": credential.musickey, "tmeLoginType": str(credential.login_type)})
|
||||
common.update(common_override)
|
||||
return common
|
||||
'''builddata'''
|
||||
@staticmethod
|
||||
def builddata(params: dict, module: str, method: str, process_bool: bool = True):
|
||||
params = {k: int(v) if isinstance(v, bool) else v for k, v in params.items()} if process_bool else params
|
||||
return {"module": module, "method": method, "param": params}
|
||||
'''buildrequestdata'''
|
||||
@staticmethod
|
||||
def buildrequestdata(params: dict, module: str, method: str, credential: Credential = None, common_override: dict = None, process_bool: bool = True) -> dict[str, Any]:
|
||||
return {"comm": QQMusicClientUtils.buildcommonparams(credential, common_override), f"{module}.{method}": QQMusicClientUtils.builddata(params, module, method, process_bool)}
|
||||
Reference in New Issue
Block a user