用 MusicKit JS v3 做一个网页试听台:页面从你自己的后端拿开发者令牌,未登录时播放试听片段;听众用自己的 Apple Music 账号登录后,可以完整播放(需要有效订阅)。
.p8私钥始终只放在后端。
先看清成品
用无头 Chrome 加载页面,令牌是自签的(Apple 不认识):MusicKit 加载成功(版本
3.2526.0-prerelease.x),MusicKit.configure()没有报错,按钮也变成了可用状态;点击播放后,请求https://api.music.apple.com/v1/catalog/us/songs?ids=…返回 401,页面显示“播放失败:Unable to read body as JSON …”(见调试示例)。没有用有效令牌和真实账号测试过试听、登录和完整播放。
注意:Apple Music API 和 MusicKit 要求你是 Apple Developer Program 会员(会员费以官方页面为准,本文不列数字);完整播放还需要听众自己有 Apple Music 订阅。只使用你所在地区的 storefront,不要用它绕过地区限制;不要下载、录制或转存音频;不要共享 Apple ID 或 .p8 私钥。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
index.html |
加载、配置、试听、登录和退出、显示正在播放的歌曲 |
token_server.py |
本地开发用:提供静态页面,并在 /token 签发 1 天有效、限定 origin 的令牌 |
e2e.py |
用自签令牌验证页面的出错处理 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| A 篇 | 能签发令牌 | token_server.py 会引用 A 篇的 make_token.py |
| 浏览器 | 近期版本 | 页面要通过 http(s) 打开 |
| 听众 | 试听不需要账号;完整播放需要自己的 Apple Music 订阅 | 不要共享账号 |
文件结构
32-apple-music-C-src/
├── index.html
├── token_server.py
└── e2e.py
第 1 步:页面
index.html:
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Apple Music 试听台</title>
<meta name="apple-music-developer-token" content="">
<style>body{font-family:sans-serif;max-width:640px;margin:24px auto}button{margin-right:8px}</style></head>
<body>
<h1>试听台</h1>
<p>歌曲 ID:<input id="song" value="" placeholder="music.apple.com 链接里 ?i= 后面的数字" size="36"></p>
<button id="play" disabled>播放</button><button id="pause" disabled>暂停</button>
<button id="login" disabled>登录 Apple Music(完整播放)</button><button id="logout" hidden>退出</button>
<p id="status">正在加载 MusicKit…</p>
<p id="now"></p>
<script>
const $ = (id) => document.getElementById(id);
const say = (s) => ($("status").textContent = s);
// 开发者令牌由你自己的服务器签发(见 A 篇);不要把 .p8 私钥放到前端
async function getDeveloperToken() {
const r = await fetch("/token"); // 你的后端接口,返回纯文本 JWT
if (!r.ok) throw new Error("取开发者令牌失败:" + r.status);
return (await r.text()).trim();
}
document.addEventListener("musickitloaded", async () => {
try {
const music = await MusicKit.configure({
developerToken: await getDeveloperToken(),
app: { name: "试听台", build: "1.0.0" },
});
window.__music = music;
["play", "pause", "login"].forEach((id) => ($(id).disabled = false));
const refresh = () => { $("login").hidden = music.isAuthorized; $("logout").hidden = !music.isAuthorized; };
refresh();
say(music.isAuthorized ? "已登录:可完整播放(需要有效订阅)" : "未登录:只能播放试听片段");
$("play").onclick = async () => {
const id = $("song").value.trim();
if (!/^\d+$/.test(id)) return say("歌曲 ID 应该是纯数字");
try { await music.setQueue({ song: id }); await music.play(); }
catch (e) { say("播放失败:" + (e.message || e)); }
};
$("pause").onclick = () => music.pause();
$("login").onclick = async () => { try { await music.authorize(); } catch (e) { say("登录取消或失败"); } refresh(); };
$("logout").onclick = async () => { await music.unauthorize(); refresh(); say("已退出"); };
music.addEventListener("nowPlayingItemDidChange", () => {
const it = music.nowPlayingItem;
$("now").textContent = it ? `正在播放:${it.title} — ${it.artistName}` : "";
});
} catch (e) {
say("初始化失败:" + (e.message || e)); window.__initError = String(e.message || e);
}
});
</script>
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js" async></script>
</body>
</html>
要点:
- 先监听
musickitloaded事件,再加载https://js-cdn.music.apple.com/musickit/v3/musickit.js。 MusicKit.configure({ developerToken, app: { name, build } })返回配置好的实例;authorize()打开 Apple 的登录窗口;setQueue({ song: id })之后调用play()。- 歌曲 ID 就是 music.apple.com 链接里
?i=后面的那串数字。
第 2 步:本地令牌服务
token_server.py:
"""本地开发用:同时提供静态页面和 /token(用 A 篇的 make_token 签发 1 天有效、限定 origin 的令牌)。
用法:python token_server.py AuthKey_XXXX.p8 KEYID TEAMID 然后打开 http://127.0.0.1:8000/"""
import functools, http.server, sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "32-apple-music-A-src"))
from make_token import make_token
P8, KID, TEAM = sys.argv[1:4]
ORIGIN = "http://127.0.0.1:8000"
class H(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path != "/token": return super().do_GET()
t = make_token(open(P8).read(), KID, TEAM, days=1, origins=[ORIGIN]).encode()
self.send_response(200); self.send_header("Content-Type", "text/plain"); self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(t))); self.end_headers(); self.wfile.write(t)
if __name__ == "__main__":
d = os.path.dirname(os.path.abspath(__file__))
http.server.ThreadingHTTPServer(("127.0.0.1", 8000), functools.partial(H, directory=d)).serve_forever()
python token_server.py AuthKey_XXXX.p8 KEYID TEAMID
# 打开 http://127.0.0.1:8000/
令牌带了 origin: http://127.0.0.1:8000,有效期 1 天。正式上线时,把这个接口放到你自己的后端,加上访问控制和缓存,不要让任何人都能无限制地取令牌。
第 3 步:出错处理测试
e2e.py:
"""用一个 Apple 不认识的自签令牌加载页面,观察 MusicKit 的行为(预期初始化失败或按钮不可用)。"""
import asyncio, functools, http.server, threading, sys, os
from playwright.async_api import async_playwright
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "32-apple-music-A-src"))
from make_token import make_token
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
PEM = ec.generate_private_key(ec.SECP256R1()).private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()
D = os.path.dirname(os.path.abspath(__file__))
class H(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
if self.path != "/token": return super().do_GET()
t = make_token(PEM, "ABC123DEFG", "DEF123GHIJ", days=1).encode()
self.send_response(200); self.send_header("Content-Length", str(len(t))); self.end_headers(); self.wfile.write(t)
async def main():
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(H, directory=D))
threading.Thread(target=srv.serve_forever, daemon=True).start()
async with async_playwright() as p:
b = await p.chromium.launch(channel="chrome"); pg = await b.new_page()
pg.on("console", lambda m: print("console:", m.type, m.text[:160]) if m.type in ("error", "warning") and "favicon" not in m.text and "404 (File" not in m.text else None)
await pg.goto(f"http://127.0.0.1:{srv.server_address[1]}/index.html")
await pg.wait_for_function("window.MusicKit !== undefined", timeout=30000)
print("MusicKit 版本:", await pg.evaluate("MusicKit.version"))
await pg.wait_for_timeout(8000)
print("status:", await pg.inner_text("#status"))
print("play 按钮 disabled:", await pg.evaluate("document.getElementById('play').disabled"))
await pg.fill("#song", "1641309998"); await pg.click("#play"); await pg.wait_for_timeout(6000)
print("点击播放后 status:", await pg.inner_text("#status"))
await b.close()
srv.shutdown()
asyncio.run(main())
本机输出:
MusicKit 版本: 3.2526.0-prerelease.x
status: 未登录:只能播放试听片段
play 按钮 disabled: False
console: error Failed to load resource: the server responded with a status of 401 ()
点击播放后 status: 播放失败:Unable to read body as JSON for https://api.music.apple.com/v1/catalog/us/songs?ids=1641309998
调试示例
令牌明明无效,页面却显示“未登录:只能播放试听片段”
本来以为 configure() 会校验令牌,所以把出错提示写在了它的 catch 里。实际测试发现,configure() 并不发请求,自签的令牌也能通过,直到点击播放时才第一次请求 API,返回 401,而且响应体是空的(和 B 篇一样),于是报了 Unable to read body as JSON。由此得到两条改进:
- 播放失败的提示里要提到“可能是令牌问题”,不要只显示原始报错。
- 上线前用 A 篇的
curl /v1/test先验证令牌,不要依赖页面初始化成功来判断令牌是否有效。
另外请注意,请求里的 storefront 是 us:未登录时,MusicKit 用了自己的默认值。如果你的听众在其他地区,要按 MusicKit 文档确认怎样指定 storefront,这一点本机没有验证。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 一直显示“正在加载 MusicKit…” | 脚本被拦截了,或者事件监听写得太晚 | 先监听事件,再加载脚本 |
| 播放时 401 | 令牌无效,或者 origin 不匹配 | 用 /v1/test 验证令牌;origin 要写完整的协议和端口 |
| 登录后还是只能试听 | 这个账号没有有效的订阅 | 正常现象;引导用户自己订阅 |
| 某首歌播放不了 | 当前 storefront 里没有这首歌 | 换一首;不要切换地区去绕过限制 |
模板规则
- 前端只拿短期的开发者令牌,私钥留在后端。
- 令牌是否有效,用
/v1/test单独验证。 - 完整播放必须由听众用自己的订阅登录。
项目资源
| 用途 | 地址 |
|---|---|
| MusicKit 总览 | https://developer.apple.com/musickit/ |
| MusicKit JS v3 文档 | https://js-cdn.music.apple.com/musickit/v3/docs/index.html |
| User Authentication for MusicKit | https://developer.apple.com/documentation/applemusicapi/user-authentication-for-musickit |
| Generating Developer Tokens | https://developer.apple.com/documentation/applemusicapi/generating-developer-tokens |
| Apple 媒体服务条款 | https://www.apple.com/legal/internet-services/itunes/ |
MusicKit JS 让网页可以直接调用 Apple Music:未登录可以试听,登录后可以完整播放。令牌在第一次请求 API 时才会被校验,所以要单独验证,并把出错提示写清楚。