''' 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 = "" 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)}