- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
|
||
|
||
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
|
||
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
|
||
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
|
||
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
|
||
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
|
||
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
|
||
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import re
|
||
import time
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
|
||
|
||
from app.sdk.auth_store import get_credentials
|
||
from app.sdk.common.request import HttpResponse, http_fetch
|
||
from app.sdk.common.utils import as_dict, as_list, format_play_time
|
||
|
||
APPLE_HOMEPAGE_URL = "https://music.apple.com"
|
||
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
|
||
DEFAULT_STOREFRONT = "us"
|
||
APPLE_UA = (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
|
||
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
||
|
||
_cached_token = ""
|
||
_cached_at = 0.0
|
||
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
|
||
_bypass_explicit_token = False
|
||
_token_lock = asyncio.Lock()
|
||
|
||
|
||
def parse_cookie_parts(cookie: str) -> dict[str, str]:
|
||
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
|
||
result: dict[str, str] = {}
|
||
for part in str(cookie or "").split(";"):
|
||
trimmed = part.strip()
|
||
idx = trimmed.find("=")
|
||
if idx > 0:
|
||
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
|
||
return result
|
||
|
||
|
||
def get_apple_config() -> dict[str, str]:
|
||
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
|
||
cred = get_credentials("apple") or {}
|
||
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
|
||
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
|
||
return {
|
||
"storefront": storefront or DEFAULT_STOREFRONT,
|
||
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
|
||
"token": str(cred.get("token") or parts.get("token") or "").strip(),
|
||
}
|
||
|
||
|
||
def decode_apple_token_payload(token: str) -> dict[str, Any]:
|
||
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
|
||
try:
|
||
part = str(token or "").split(".")[1]
|
||
padded = part.replace("-", "+").replace("_", "/")
|
||
padded += "=" * (-len(padded) % 4)
|
||
data = json.loads(base64.b64decode(padded).decode("utf-8"))
|
||
return data if isinstance(data, dict) else {}
|
||
except (IndexError, ValueError, TypeError):
|
||
return {}
|
||
|
||
|
||
def is_apple_token_expired(token: str) -> bool:
|
||
try:
|
||
exp = float(decode_apple_token_payload(token).get("exp") or 0)
|
||
except (TypeError, ValueError):
|
||
exp = 0
|
||
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
|
||
|
||
|
||
def extract_apple_tokens(js: str) -> list[str]:
|
||
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
|
||
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
|
||
found: list[str] = []
|
||
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
|
||
if not found:
|
||
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
|
||
# 去重且保持顺序
|
||
return list(dict.fromkeys(found))
|
||
|
||
|
||
def pick_apple_token(js: str) -> str:
|
||
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
|
||
tokens = extract_apple_tokens(js)
|
||
if not tokens:
|
||
return ""
|
||
alive = [token for token in tokens if not is_apple_token_expired(token)]
|
||
if not alive:
|
||
return ""
|
||
preferred = next(
|
||
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
|
||
)
|
||
return preferred or alive[0] or ""
|
||
|
||
|
||
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
|
||
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
|
||
current = str(url)
|
||
response = None
|
||
for _hop in range(max_hops):
|
||
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
|
||
status = response.status_code or 0
|
||
location = response.headers.get("location", "")
|
||
if 300 <= status < 400 and location:
|
||
current = urljoin(current, location)
|
||
continue
|
||
return response
|
||
raise RuntimeError("apple: too many redirects")
|
||
|
||
|
||
def _response_text(response: HttpResponse) -> str:
|
||
if isinstance(response.body, str):
|
||
return response.body
|
||
if response.raw:
|
||
return response.raw.decode("utf-8", errors="replace")
|
||
return str(response.body or "")
|
||
|
||
|
||
async def fetch_apple_token() -> str:
|
||
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
|
||
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
|
||
if home.status_code != 200:
|
||
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
|
||
html = _response_text(home)
|
||
|
||
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
|
||
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
|
||
if not match:
|
||
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
|
||
if not match:
|
||
raise RuntimeError("apple: index.js URI not found")
|
||
|
||
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
|
||
if js_res.status_code != 200:
|
||
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
|
||
token = pick_apple_token(_response_text(js_res))
|
||
if not token:
|
||
raise RuntimeError("apple: token not found in index.js")
|
||
return token
|
||
|
||
|
||
async def get_apple_token(force: bool = False) -> str:
|
||
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
|
||
global _cached_token, _cached_at
|
||
cfg = get_apple_config()
|
||
if not force and not _bypass_explicit_token and cfg["token"]:
|
||
return cfg["token"]
|
||
if (
|
||
not force
|
||
and _cached_token
|
||
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
|
||
and not is_apple_token_expired(_cached_token)
|
||
):
|
||
return _cached_token
|
||
async with _token_lock:
|
||
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
|
||
cfg = get_apple_config()
|
||
if not force and not _bypass_explicit_token and cfg["token"]:
|
||
return cfg["token"]
|
||
if (
|
||
not force
|
||
and _cached_token
|
||
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
|
||
and not is_apple_token_expired(_cached_token)
|
||
):
|
||
return _cached_token
|
||
token = await fetch_apple_token()
|
||
_cached_token = token
|
||
_cached_at = time.time() * 1000
|
||
return token
|
||
|
||
|
||
def invalidate_apple_token() -> None:
|
||
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
|
||
global _cached_token, _cached_at, _bypass_explicit_token
|
||
_cached_token = ""
|
||
_cached_at = 0.0
|
||
_bypass_explicit_token = True
|
||
|
||
|
||
def reset_apple_token_state() -> None:
|
||
"""仅测试/调试用:重置内存态。"""
|
||
global _cached_token, _cached_at, _bypass_explicit_token
|
||
_cached_token = ""
|
||
_cached_at = 0.0
|
||
_bypass_explicit_token = False
|
||
|
||
|
||
def apple_amp_headers(token: str) -> dict[str, str]:
|
||
cfg = get_apple_config()
|
||
headers = {
|
||
"Authorization": f"Bearer {token}",
|
||
"Origin": APPLE_HOMEPAGE_URL,
|
||
"Referer": f"{APPLE_HOMEPAGE_URL}/",
|
||
"User-Agent": APPLE_UA,
|
||
"Accept": "application/json",
|
||
}
|
||
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
|
||
if cfg["mediaUserToken"]:
|
||
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
|
||
return headers
|
||
|
||
|
||
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
|
||
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
|
||
raw = str(uri or "")
|
||
if re.match(r"^https?://", raw, re.IGNORECASE):
|
||
base = raw
|
||
elif raw.startswith("/"):
|
||
base = APPLE_AMP_API_URL + raw
|
||
else:
|
||
base = f"{APPLE_AMP_API_URL}/{raw}"
|
||
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
|
||
if not pairs:
|
||
return base
|
||
query = urlencode(pairs)
|
||
return base + ("&" if "?" in base else "?") + query
|
||
|
||
|
||
def _amp_error_detail(response: HttpResponse) -> str:
|
||
body = response.body if isinstance(response.body, dict) else {}
|
||
errors = as_list(body.get("errors"))
|
||
first = as_dict(errors[0]) if errors else {}
|
||
return str(first.get("detail") or first.get("title") or "")
|
||
|
||
|
||
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
|
||
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
|
||
url = build_apple_amp_url(uri, params)
|
||
|
||
async def attempt() -> HttpResponse:
|
||
token = await get_apple_token()
|
||
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
|
||
|
||
response = await attempt()
|
||
if response.status_code in (401, 403):
|
||
invalidate_apple_token()
|
||
response = await attempt()
|
||
if not (200 <= response.status_code < 300):
|
||
detail = _amp_error_detail(response)
|
||
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
|
||
return response.body
|
||
|
||
|
||
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
|
||
return await apple_amp_get_tracked(uri, params)
|
||
|
||
|
||
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
|
||
MAX_TRACK_PAGES = 20
|
||
|
||
|
||
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
|
||
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
|
||
tracks = as_dict(tracks)
|
||
all_tracks: list[Any] = list(as_list(tracks.get("data")))
|
||
next_uri = str(tracks.get("next") or "").strip()
|
||
page = 0
|
||
while next_uri and page < MAX_TRACK_PAGES:
|
||
body = as_dict(await apple_amp_get_tracked(next_uri))
|
||
all_tracks.extend(as_list(body.get("data")))
|
||
next_uri = str(body.get("next") or "").strip()
|
||
page += 1
|
||
return all_tracks
|
||
|
||
|
||
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
|
||
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
|
||
artwork = as_dict(artwork)
|
||
url = str(artwork.get("url") or "")
|
||
if not url:
|
||
return ""
|
||
return url.replace("{w}", str(size)).replace("{h}", str(size))
|
||
|
||
|
||
def _round_number(value: Any) -> int:
|
||
"""JS Math.round 的等价实现(.5 向上取整)。"""
|
||
import math
|
||
|
||
try:
|
||
number = float(value or 0)
|
||
except (TypeError, ValueError):
|
||
number = 0.0
|
||
return math.floor(number + 0.5)
|
||
|
||
|
||
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
|
||
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
|
||
res = as_dict(res)
|
||
attr = as_dict(res.get("attributes"))
|
||
previews = as_list(attr.get("previews"))
|
||
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
|
||
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
|
||
return {
|
||
"songmid": str(res.get("id") or ""),
|
||
"name": attr.get("name") or "",
|
||
"singer": attr.get("artistName") or "",
|
||
"albumId": str(attr.get("albumId") or default_album_id or ""),
|
||
"albumName": attr.get("albumName") or "",
|
||
"interval": format_play_time(duration_sec),
|
||
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
|
||
"lrc": None,
|
||
"types": [],
|
||
"_types": {},
|
||
"source": "apple",
|
||
"link": attr.get("url") or "", # music.apple.com 网页地址
|
||
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
|
||
"duration": duration_sec,
|
||
"isrc": attr.get("isrc") or "",
|
||
}
|
||
|
||
|
||
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
|
||
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
|
||
res = as_dict(res)
|
||
attr = as_dict(res.get("attributes"))
|
||
notes = as_dict(attr.get("editorialNotes"))
|
||
return {
|
||
"id": str(res.get("id") or ""),
|
||
"name": attr.get("name") or "",
|
||
"cover": apple_artwork_url(attr.get("artwork"), 300),
|
||
"trackCount": int(attr.get("trackCount") or 0),
|
||
"creator": attr.get("artistName") or "",
|
||
"description": notes.get("short") or "",
|
||
"link": attr.get("url") or "",
|
||
"source": "apple",
|
||
"extra": {
|
||
"release_date": attr.get("releaseDate") or "",
|
||
"record_label": attr.get("recordLabel") or "",
|
||
},
|
||
}
|
||
|
||
|
||
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
|
||
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
|
||
res = as_dict(res)
|
||
attr = as_dict(res.get("attributes"))
|
||
description = as_dict(attr.get("description"))
|
||
notes = as_dict(attr.get("editorialNotes"))
|
||
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
|
||
return {
|
||
"id": str(res.get("id") or ""),
|
||
"name": attr.get("name") or "",
|
||
"cover": apple_artwork_url(attr.get("artwork"), 300),
|
||
"trackCount": int(attr.get("trackCount") or 0),
|
||
"creator": attr.get("curatorName") or "",
|
||
"description": desc,
|
||
"link": attr.get("url") or "",
|
||
"source": "apple",
|
||
}
|
||
|
||
|
||
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
|
||
sf = str(storefront or DEFAULT_STOREFRONT)
|
||
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
|
||
|
||
|
||
def is_apple_raw_id(value: str, media_type: str) -> bool:
|
||
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
|
||
if re.search(r"[/?#]", value) or "://" in value:
|
||
return False
|
||
if media_type == "playlist":
|
||
if value.startswith("pl."):
|
||
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
|
||
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
|
||
return re.fullmatch(r"\d+", value) is not None
|
||
|
||
|
||
def extract_apple_id_from_path(path: str, media_type: str) -> str:
|
||
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
|
||
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
|
||
if not trimmed:
|
||
return ""
|
||
segments = trimmed.split("/")
|
||
for index, segment in enumerate(segments):
|
||
if segment != media_type and segment != f"{media_type}s":
|
||
continue
|
||
if index + 2 < len(segments):
|
||
return segments[index + 2]
|
||
if index + 1 < len(segments):
|
||
return segments[index + 1]
|
||
return ""
|
||
|
||
|
||
def extract_apple_id(link: str, media_type: str) -> str:
|
||
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
|
||
raw = str(link or "").strip()
|
||
if not raw:
|
||
return ""
|
||
if is_apple_raw_id(raw, media_type):
|
||
return raw
|
||
|
||
path = ""
|
||
query = ""
|
||
try:
|
||
parts = urlsplit(raw)
|
||
if parts.scheme or parts.netloc:
|
||
path = parts.path or ""
|
||
query = f"?{parts.query}" if parts.query else ""
|
||
except ValueError:
|
||
path, query = "", ""
|
||
if not path:
|
||
idx = raw.find("?")
|
||
path = raw[:idx] if idx >= 0 else raw
|
||
query = raw[idx:] if idx >= 0 else ""
|
||
|
||
if path:
|
||
if media_type == "song":
|
||
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
|
||
song_id = values[0].strip() if values else ""
|
||
if song_id:
|
||
return song_id
|
||
from_path = extract_apple_id_from_path(path, media_type)
|
||
if from_path:
|
||
return from_path
|
||
|
||
patterns = [
|
||
rf"{media_type}/[^/]+/(\d+)",
|
||
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
|
||
r"/(\d+)$",
|
||
r"[?&]i=(\d+)",
|
||
]
|
||
if media_type == "playlist":
|
||
patterns.extend(
|
||
[
|
||
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
|
||
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
|
||
]
|
||
)
|
||
for pattern in patterns:
|
||
match = re.search(pattern, raw)
|
||
if match:
|
||
return match.group(1)
|
||
return ""
|
||
|
||
|
||
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
|
||
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
|
||
cfg = get_apple_config()
|
||
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
|
||
if include:
|
||
params["include"] = include
|
||
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
|
||
data = as_list(body.get("data"))
|
||
return data[0] if data else None
|
||
|
||
|
||
def quote_path_segment(value: str) -> str:
|
||
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
|
||
return quote(str(value), safe="._-")
|