效率工具

Apple Music:用 MusicKit JS v3 做一个网页试听台

用 MusicKit JS v3 做一个网页试听台:页面从你自己的后端拿开发者令牌,未登录时播放试听片段;听众用自己的 Apple Music 账号登录后,可以完整播放(需要有效订阅)。.p8 私钥始终只放在后端。

BINBHO · 图文教程

用 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。由此得到两条改进:

  1. 播放失败的提示里要提到“可能是令牌问题”,不要只显示原始报错。
  2. 上线前用 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 时才会被校验,所以要单独验证,并把出错提示写清楚。