feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 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 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
凭据:auth_store 的 apple 记录,cookie 形如
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
from app.sdk.platforms.apple.util import (
apple_artwork_url,
apple_song_from_catalog,
apple_web_url,
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
get_apple_token,
)
def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _init() -> bool:
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
try:
await get_apple_token()
return True
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
return False
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
if song_info.get("img"):
return str(song_info["img"])
song_id = _song_id_of(song_info)
if not song_id:
return None
try:
res = await fetch_apple_song_resource(song_id)
if not res:
return None
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
except Exception: # noqa: BLE001 - 封面取不到返回 None
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
song_id = _song_id_of(song_info)
if not song_id:
return None
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
return apple_song_from_catalog(res) if res else None
class _AppleAlbum:
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
get_album_detail = staticmethod(album.get_album_detail)
get_album_songs = staticmethod(album.get_album_songs)
search_album = staticmethod(album.search_album)
parse_album_link = staticmethod(album.parse_album_link)
class _AppleSongList:
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate, page, limit)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
song_id = _song_id_of(song_info)
if not song_id:
return "https://music.apple.com/"
return apple_web_url("song", song_id, get_apple_config()["storefront"])
def status() -> dict[str, Any]:
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
cred = get_credentials("apple") or {}
cfg = get_apple_config()
if cfg["mediaUserToken"] or cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="apple",
name="Apple Music",
init=_init,
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
song_list=_AppleSongList,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+89
View File
@@ -0,0 +1,89 @@
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_album
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
album_id = extract_apple_id(str(raw_id or ""), "album")
if not album_id:
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
)
data = as_list(body.get("data"))
album = as_dict(data[0]) if data else {}
if not album:
raise RuntimeError(f"apple album not found: {album_id}")
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 50))
summary = apple_album_to_playlist(album)
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": album_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
"releaseDate": summary["extra"]["release_date"],
"recordLabel": summary["extra"]["record_label"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只返回曲目数组。"""
detail = await get_album_detail(album_id, 1, 100000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
detail = await get_album_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
+45
View File
@@ -0,0 +1,45 @@
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。
通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
→ data[0].relationships.lyrics.data[*].attributes.ttml
上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
(即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词需要 media-user-token(订阅态);
本实现保持上游行为:拿不到就返回 ``{lyric: '', tlyric: ''}``,不抛错。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"include": "lyrics", "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
first = as_dict(data[0]) if data else {}
relationships = as_dict(first.get("relationships"))
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
for rel in lyrics:
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
if text:
return {"lyric": text, "tlyric": ""}
return {"lyric": "", "tlyric": ""}
async def get_lyrics_text(song_id: str) -> str:
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
result = await get_lyric({"songmid": song_id})
return result.get("lyric") or ""
+90
View File
@@ -0,0 +1,90 @@
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
get_apple_config,
)
#: amp-api search 单页硬上限
SEARCH_PAGE_MAX = 25
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
word = str(keyword or "").strip()
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 100)
if not word:
return {"body": None, "page": p, "limit": size}
cfg = get_apple_config()
body = await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/search",
{
"term": word,
"types": types,
"limit": min(size, SEARCH_PAGE_MAX),
"offset": (p - 1) * size,
},
)
return {"body": body, "page": p, "limit": size}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,amp-api 无重试语义
result = await _catalog_search(keyword, "songs", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
bucket = as_dict(results.get("songs"))
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
has_next = bool(bucket.get("next"))
return {
"list": songs,
"allPage": p + 1 if has_next else p,
"total": (p - 1) * size + len(songs),
"limit": size,
"source": "apple",
}
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
result = await _catalog_search(keyword, "albums", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("albums")).get("data"))
songs = [apple_album_to_playlist(item) for item in items]
return {
"list": songs,
"total": (p - 1) * size + len(songs),
"page": p,
"limit": size,
"source": "apple",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist:搜索歌单。"""
result = await _catalog_search(keyword, "playlists", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("playlists")).get("data"))
playlists = [apple_playlist_to_playlist(item) for item in items]
return {
"list": playlists,
"total": (p - 1) * size + len(playlists),
"page": p,
"limit": size,
"source": "apple",
}
+34
View File
@@ -0,0 +1,34 @@
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。
song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.apple.album import parse_album_link
from app.sdk.platforms.apple.playlist import parse_playlist_link
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
song_id = extract_apple_id(str(link or ""), "song")
if not song_id:
raise RuntimeError(f"invalid apple music song link: {link}")
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
if not res:
raise RuntimeError(f"apple song not found: {song_id}")
return apple_song_from_catalog(res)
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+177
View File
@@ -0,0 +1,177 @@
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_playlist
from app.sdk.platforms.apple.util import (
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
{"id": "1526756058", "name": "热门"},
{"id": "1479949880", "name": "C-Pop"},
{"id": "1019400042", "name": "国语流行"},
{"id": "1019398918", "name": "粤语流行"},
{"id": "1019399540", "name": "国际流行"},
{"id": "1019399551", "name": "K-Pop"},
{"id": "1019399547", "name": "J-Pop"},
{"id": "989061415", "name": "嘻哈/说唱"},
{"id": "1019400044", "name": "R&B"},
{"id": "1019400046", "name": "摇滚"},
{"id": "1019397973", "name": "另类音乐"},
{"id": "976439535", "name": "舞曲"},
{"id": "976439536", "name": "电子"},
{"id": "976439541", "name": "独立音乐"},
{"id": "1019399549", "name": "爵士乐"},
{"id": "1019398924", "name": "古典音乐"},
{"id": "976439528", "name": "蓝调"},
{"id": "976439534", "name": "乡村音乐"},
{"id": "1531542847", "name": "拉丁音乐"},
{"id": "988656348", "name": "非洲音乐"},
{"id": "976439543", "name": "金属乐"},
{"id": "976439550", "name": "朋克乐"},
{"id": "1019400049", "name": "不插电"},
{"id": "1231181168", "name": "影视原声"},
{"id": "1441811365", "name": "DJ 混音精选"},
{"id": "1532467784", "name": "瞩目之星"},
{"id": "1554938339", "name": "年代之声"},
{"id": "1564180390", "name": "空间音频"},
{"id": "989010186", "name": "亲子"},
]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
return {
"source": "apple",
"list": [
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
for item in APPLE_CURATOR_CATEGORIES
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
cate = str(category_id or "").strip()
if not cate:
raise RuntimeError("apple 分类 id 不能为空")
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 300)
api_max = 25
offset = (p - 1) * size
all_playlists: list[dict[str, Any]] = []
while len(all_playlists) < size:
batch_size = min(api_max, size - len(all_playlists))
try:
body = as_dict(
await apple_amp_get(
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
)
)
except Exception: # 上游:已有数据时返回已取部分
if all_playlists:
break
raise
data = as_list(body.get("data"))
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
# 没有更多数据或没有下一页
if len(data) < batch_size or not body.get("next"):
break
offset += len(data)
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(全量曲目后按 page/limit 切片)。"""
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
if not playlist_id:
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
item = as_dict(data[0]) if data else {}
if not item:
raise RuntimeError(f"apple playlist not found: {playlist_id}")
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 100))
summary = apple_playlist_to_playlist(item)
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": playlist_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只返回曲目数组。"""
detail = await get_playlist_detail(playlist_id, 1, 100000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
detail = await get_playlist_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = [
"APPLE_CURATOR_CATEGORIES",
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"parse_playlist_link",
"search_playlist",
]
+465
View File
@@ -0,0 +1,465 @@
"""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="._-")