Starry Player 的插件是一个 JavaScript 文件,由播放器的 JavaScriptCore 执行。插件可以提供音源、歌词源,也可以同时提供两者;界面、播放队列、音频下载和歌词匹配由播放器负责,插件负责把平台接口转换成播放器认识的数据。
本文从一个不需要联网的歌词插件开始,再搭建可构建、可测试的 TypeScript 音源工程。接口以当前 API version 1 为准,完整签名见 plugins/sdk/starry.d.ts,简要参考见 SDK 说明。
- 1. 运行环境与插件结构
- 2. 第一个插件:离线歌词源
- 3. 创建 TypeScript 工程
- 4. 接入搜索与播放
- 5. 网络请求与权限
- 6. 歌词接入与逐字歌词
- 7. 设置、存储与生命周期
- 8. 登录与多账号
- 9. 扩展音源功能
- 10. 高级播放能力
- 11. 调试与错误处理
- 12. 自动化验证
- 13. 打包、安装与更新
- 14. 继续阅读
| 插件类型 | 导出的分组 | 必需函数 | 典型用途 |
|---|---|---|---|
| 音源 | source |
resolve |
搜索音乐并取得播放地址 |
| 歌词源 | lyrics |
search、fetch |
为各个音源的歌曲匹配歌词 |
| 带登录的音源 | source、account |
上述音源函数,以及 refresh、logout |
自建服务器、登录后访问收藏 |
| 音源及歌词源 | source、lyrics,可加 account |
各分组的必需函数 | 完整接入一个音乐平台 |
account 不能单独导出,必须搭配 source。不支持的可选功能直接省略,不要为了占位导出空函数:播放器会根据函数和相关声明决定开放哪些功能。
清单就是导出对象上的字段,不需要额外的 manifest.json。
| 字段 | 说明 |
|---|---|
id |
稳定、唯一的标识,推荐反向域名,如 dev.example.music。只能用小写字母、数字及分隔符 . _ -,分隔符不能连续或出现在首尾,长度 1~100;发布后不要改 |
name |
在播放器里显示的名称,不能为空 |
version |
非空版本字符串,推荐使用 1.0.0 形式;与 npm 工程版本保持一致 |
apiVersion |
当前填写数字 1 |
permissions.hosts |
插件 HTTP 请求允许访问的域名;不联网时写 [] |
author、description、homepage |
可选的作者、介绍、项目主页 |
icon |
可选的 SF Symbol 名称,如 music.note、text.quote、server.rack |
idNamespace |
可选的平台身份,仅支持 netease、qqmusic、kugou,详见发布章节 |
settings、onSettingsChanged |
可选的设置声明及变更回调 |
宿主提供 Promise、typed array 等 JavaScript 标准能力,以及 console、定时器、TextEncoder / TextDecoder、URL / URLSearchParams、Headers、atob / btoa 和 fetch 子集。网络、持久化、加密与压缩通过全局 starry 对象访问。
插件里没有 DOM、Node.js 的 require、process、Buffer、文件系统或子进程。Node.js 只是本地的构建和测试工具。npm 依赖必须能在此环境中运行,并被打包进最终文件;打包成功并不代表依赖需要的运行时 API 都存在。
普通 JavaScript 使用 module.exports = { ... }。TypeScript 工程可以使用 import / export default,再打包成 CommonJS;宿主也识别打包后的 module.exports.default。不要直接加载 .ts 或仍然包含外部模块依赖的产物。
先用固定数据验证“加载 → 搜索 → 获取歌词”,避免一开始就受远端接口影响。
在仓库根目录执行:
mkdir -p plugins/tutorial-lyrics创建 plugins/tutorial-lyrics/demo.js:
// @ts-check
/// <reference path="../sdk/starry.d.ts" />
/** @type {import('../sdk/starry').Plugin} */
module.exports = {
id: 'dev.example.tutorial-lyrics',
name: '教程歌词',
version: '1.0.0',
apiVersion: 1,
description: '用于验证插件调用的离线示例',
icon: 'text.quote',
permissions: { hosts: [] },
lyrics: {
detail: '离线 LRC 示例',
async search(keyword) {
if (!keyword.includes('星光练习曲')) return [];
return [{
id: 'demo-1',
title: '星光练习曲',
artists: ['示例歌手'],
duration: 20,
}];
},
async fetch(song) {
if (song.id !== 'demo-1') return null;
return {
format: 'lrc',
body: '[00:00.00]星光落在窗前\n[00:05.00]音乐慢慢响起\n[00:10.00]让这一刻继续',
};
},
},
};打开播放器设置并开启高级设置,在 设置 › 插件 › 开发者 › 开发中的插件 添加 demo.js,或添加它所在的 tutorial-lyrics 文件夹。插件列表中应出现“教程歌词”,歌词来源设置中也会出现它。
播放一首标题为“星光练习曲”、歌手为“示例歌手”、时长约 20 秒的测试音频,并在歌词来源设置中启用此源,即可验证自动匹配。没有这样的音频时,也可以先在调试器中直接调用 module.exports.lyrics.search('星光练习曲') 和 module.exports.lyrics.fetch({ id: 'demo-1' }),确认候选结果和 LRC 文本。
开发文件夹只扫描当前层级的 .js 文件,不会递归进入 src、dist。修改文件后需要点击“重新加载”;播放器没有自动监听文件变化。
以下路径均以本仓库为基础。若插件放在独立仓库,将 SDK 的 starry.d.ts 复制到自己的 sdk/,并相应修改 include 和类型导入路径即可;它只是类型声明,不是需要安装的运行时库。
在仓库根目录执行:
mkdir -p plugins/tutorial-source/src plugins/tutorial-source/test
cd plugins/tutorial-source
npm init -y
npm install --save-dev esbuild typescript工程结构:
plugins/tutorial-source/
package.json
package-lock.json
tsconfig.json
src/
models.ts # 平台数据转为 SDK 模型
index.ts # 清单、搜索和播放
test/
plugin.test.cjs # 用模拟接口验证构建产物
dist/
tutorial.js # 最终交给播放器的文件
保留 npm 写入的 devDependencies,将 package.json 中的 scripts 替换为:
{
"bundle": "esbuild src/index.ts --bundle --format=cjs --platform=neutral --target=safari17 --charset=utf8 --outfile=dist/tutorial.js",
"build": "npm run bundle -- --minify",
"watch": "npm run bundle -- --watch",
"check": "tsc --noEmit",
"test": "npm run build && node --test test/plugin.test.cjs"
}这里展示的是 scripts 的值,不是完整的 package.json。最终产物始终是单文件;watch 使用未压缩代码,便于下断点。
创建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": [],
"strict": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"isolatedModules": true,
"skipLibCheck": false,
"noEmit": true
},
"include": ["src", "../sdk/starry.d.ts"]
}不要添加 DOM 或 Node 类型来消除插件源码的报错,否则容易误用宿主没有的 API。测试文件在 Node 中运行,不放进这份插件源码的 include。
本节的 https://api.example.com 是接口占位符,不是真实音乐服务。示例假定搜索接口返回 { items, total, hasMore, nextOffset },播放接口返回 { url, expiresIn }。你可以先用自动化验证中的模拟响应跑通工程,再替换为目标平台的地址、字段和认证逻辑。
创建 src/models.ts:
import type { Track } from '../../sdk/starry';
export interface APISong {
id: string | number;
title: string;
artist: { id: string | number; name: string };
durationMs: number;
cover?: string;
}
export function toTrack(item: APISong): Track {
return {
id: String(item.id),
title: item.title,
artists: [{ id: String(item.artist.id), name: item.artist.name }],
duration: item.durationMs / 1000,
artwork: item.cover,
tiers: ['standard'],
};
}模型中的歌曲、歌手、专辑、歌单 ID 都应转换成稳定的字符串。Track.duration 使用秒,不要直接传平台的毫秒值;DateValue 则是 Unix 毫秒时间戳或 ISO 8601 字符串。
Track.id 必须足以在重启后重新找到歌曲。队列、收藏和歌词绑定会保存这个 ID,不能依赖某次搜索留下的内存对象来播放。封面 artwork 可以是 URL 字符串,也可以是带 url、sizedTemplate、sizeSteps 的对象;模板用 {width}、{height} 表示尺寸。
创建 src/index.ts:
import type { HttpRequest, Plugin } from '../../sdk/starry';
import { toTrack, type APISong } from './models';
const API = 'https://api.example.com';
async function getJSON<T>(path: string, query?: HttpRequest['query']): Promise<T> {
const response = await starry.http.get<T>(`${API}${path}`, {
query,
responseType: 'json',
timeout: 10,
});
if (response.status === 401) throw starry.error('loginExpired');
if (response.status === 429) throw starry.error('rateLimited', '请求过于频繁,请稍后重试');
if (response.status < 200 || response.status >= 300) {
throw starry.error('network', `接口返回 HTTP ${response.status}`);
}
return response.body;
}
interface SearchResponse {
items: APISong[];
total: number;
hasMore: boolean;
nextOffset?: number;
}
const plugin = {
id: 'dev.example.tutorial-source',
name: '教程音源',
version: '1.0.0',
apiVersion: 1,
icon: 'music.note',
permissions: { hosts: ['api.example.com'] },
source: {
searchKinds: ['song'],
qualityTiers: [{ id: 'standard', name: '标准', level: 'sq' }],
async search(query, kind, page) {
if (kind !== 'song') return {};
const result = await getJSON<SearchResponse>('/songs', {
q: query,
offset: page.offset,
limit: page.limit,
});
return {
songs: result.items.map(toTrack),
total: result.total,
hasMore: result.hasMore,
nextOffset: result.nextOffset,
};
},
async resolve(track, tier) {
const result = await getJSON<{ url: string; expiresIn: number }>(
`/songs/${encodeURIComponent(track.id)}/stream`,
{ quality: tier.id },
);
if (!result.url) throw starry.error('notSupported', '这首歌没有可用的播放地址');
return {
url: result.url,
container: 'mp3',
tier: 'standard',
expiresIn: result.expiresIn,
};
},
},
} satisfies Plugin;
export default plugin;在 plugins/tutorial-source 中运行:
npm run check
npm run build
npm run watchwatch 会持续运行。将 dist/tutorial.js 或 dist/ 添加到播放器的开发插件路径,不要添加工程根目录。后续每次保存源文件,先等 esbuild 完成,再在播放器中重新加载。
泛型 getJSON<T> 只提供编译时类型,不会校验远端 JSON。实际接入时要在映射层检查响应形状,处理字段缺失、不可播放项目和服务端返回的业务错误;不要把所有 403 都当成会员限制,应根据平台的业务码区分原因。
source.search(query, kind, page) 中,kind 是 song、album、artist、playlist 或 user;page 是 { offset, limit },offset 从 0 开始。返回对应的 songs、albums、artists、playlists、users 字段,并用 searchKinds 声明实际支持的类别。搜索用户还需要实现 source.user。
如果接口使用页码,需要把 offset 转成平台的分页参数。如果接口返回 20 条原始数据,你过滤后只留下 12 条,下一页仍应从原始数据的下一位置开始;显式返回 nextOffset,不要用过滤后的数组长度推进。hasMore 和 total 也应按接口真实语义填写,结束时返回 hasMore: false。
resolve(track, tier) 在需要播放地址时调用,返回 PlayableAsset:
| 字段 | 用法 |
|---|---|
url |
音频的 HTTP(S) 地址;应尽可能在此时获取新鲜的签名 URL |
headers |
下载音频需要的请求头,如 Authorization、Referer;解析接口用的请求头不会自动成为这里的请求头 |
container |
实际容器,如 mp3、flac、mp4、hls;缺省时尝试从 URL 后缀判断 |
tier |
实际返回的音质档位 ID;发生降级时也要准确返回 |
expiresIn |
地址从现在起的有效秒数,未提供时为 1200 秒,当前宿主会将最小缓存有效期设为 30 秒;不要填写 Unix 时间戳 |
trial |
是否只有试听片段 |
info |
可选的码率、采样率、位深、声道数、文件大小;例如 bitrate: 320000,单位为 bit/s |
supportsOverlap |
是否允许与下一首重叠播放 |
qualityTiers[].level 使用 lq、sq、hq、lossless、hi-res,用于把用户全局音质偏好映射到平台档位。Track.tiers 填当前歌曲可用的档位 ID。上面的例子只有一个 MP3 档位;实际平台支持多种格式时,应一并调整 container 和音质映射。
starry.http 和宿主的 fetch 子集都受 permissions.hosts 限制,重定向的目标域名也会检查。
| 声明 | 范围 |
|---|---|
api.example.com |
仅此主机,不包括其子域名 |
*.example.com |
example.com 本身及其子域名 |
* |
任意 HTTP(S) 主机,适用于用户自行填写地址的服务器插件 |
[] |
不允许插件发起网络请求 |
这里写主机名,不写协议、路径或端口。把插件请求及其重定向真正用到的主机列出来。仅向用户提供一个 url 与插件主动通过 starry.http 请求它是不同的调用路径:音频 URL 交给播放器下载;插件自己请求媒体信息或歌词下载地址时,仍需相应的主机权限。
推荐优先使用 starry.http,它提供查询参数、JSON、表单、Cookie 和代理选项:
// JSON 请求;body 为普通对象时,post 自动按 JSON 发送。
const login = await starry.http.post('https://api.example.com/login', {
username: 'demo',
password: '从登录函数的参数取得',
}, { responseType: 'json' });
// 表单请求。
const formResponse = await starry.http.request({
url: 'https://api.example.com/form',
method: 'POST',
form: { key: 'value' },
responseType: 'text',
});
// 二进制响应可交给编码、解密或解压接口。
const binary = await starry.http.get<Uint8Array>('https://api.example.com/lyrics', {
responseType: 'bytes',
cookies: false,
});这些是独立的调用片段,应放进相应函数,不要在模块顶层执行。responseType 默认为 text;响应包含 status、小写键名的 headers、cookies、重定向后的 url 和 body。HTTP 404、401、500 等状态不会自动抛错,要显式检查;网络连接失败或超时则会抛错。
每个插件有独立的 Cookie 环境。cookies: false 表示仅发送显式给出的 Cookie 头,并且不保存响应 Cookie;response.cookies 是本次服务器设置的值,删除的 Cookie 对应空字符串。需要跨重启或多账号恢复的会话由插件显式存储,不要只依赖内存中的 Cookie 状态。
timeout 以秒为单位,默认 15。单次插件调用还有整体超时,不能靠增大 HTTP 超时无限延长调用。proxy 可按请求指定 http://主机:端口 或 socks5://主机:端口;redirect: 'manual' 可用于自行处理跳转。
lyrics.search(keyword) 返回 LyricsSong[],与音源的 Track[] 不同:
| 字段 | 类型与用途 |
|---|---|
id |
字符串 ID,用于下一步获取歌词 |
mid |
可选的第二种平台 ID;AMLL 查询优先用它 |
title |
歌曲标题 |
artists |
字符串数组,不是音源的 { id, name }[] |
album |
可选的专辑名字符串 |
duration |
可选的时长,单位秒 |
播放器根据标题、歌手、时长匹配候选并缓存结果,插件只需返回真实、可辨认的数据。fetch(song) 必须能仅凭 id(以及平台需要时的 mid)获取歌词:同平台歌曲可能按 ID 直接获取,重启后也可能不再先调用 search。可缓存搜索结果来减少请求,但缓存未命中时必须能再次请求。
没有歌词时返回 null,搜索没有结果时返回 []。网络失败应抛错,不要伪装成“没有歌词”,否则不利于区分无结果和服务故障。
fetch 返回 { format, body, translation?, romanization? }:
format支持lrc、ttml、yrc、qrc、krc。body是明文字符串。加密的 QRC、KRC 要先解密、解压,再交给宿主解析。translation、romanization可携带独立的歌词文本;普通 LRC 接入可传带时间戳的翻译或罗马音 LRC。- 只有没有时间戳的纯文本时,不要假装成同步歌词;可返回
null让播放器继续尝试其他来源。
服务端提供逐行或逐字结构化数据时,可以参考 plugins/common/lyrics.ts 将它转换为 LRC / TTML。完整联网歌词插件可参考 LRCLIB 示例。
平台 ID 与 AMLL TTML 数据库一致时,在 lyrics 上声明 ttmlFolder,例如 ncm-lyrics。播放器会用匹配结果的 mid(优先)或 id,以及平台自己的歌曲 ID 查询该目录。
如果音源知道某首歌对应的其他平台条目,可在 Track 上返回 ttml: [{ folder: 'am-lyrics', id: '对应条目 ID' }]。这些条目随歌曲保存,并按顺序优先尝试。不要填未经确认的 ID,也不必为了这个能力把本地文件或自建服务器伪装成某个内置平台。
在插件导出对象中增加 settings,例如:
import type { SettingsSection } from '../../sdk/starry';
const settings: SettingsSection[] = [{
id: 'general',
title: '接口选项',
settings: [
{ key: 'includeTranslation', title: '加载翻译', type: 'toggle', default: true },
{ key: 'token', title: '访问令牌', type: 'text', secure: true, default: '' },
{
key: 'region', title: '区域', type: 'choice', default: 'global',
choices: [{ value: 'global', title: '全球' }, { value: 'local', title: '本地' }],
},
],
}];把这个变量赋给插件的 settings 字段。在请求函数里读取 starry.settings.includeTranslation 等值;宿主已填入默认值,值类型是字符串或布尔值。advanced: true 可用于设置项或整个分组,使它们只在高级设置开启时显示。
需要使缓存失效或重建客户端时,实现 onSettingsChanged(values)。回调执行时 starry.settings 已更新,不要写入这个只读对象,也不要在模块顶层把它复制成永不更新的常量。secure: true 用来隐藏输入显示,不能将它理解为持久化加密承诺。
starry.storage.get<T>(key)、set(key, value)、remove(key)、keys()、clear() 提供按插件隔离的持久化键值存储。值必须能序列化为 JSON;Map、函数、循环引用和 typed array 不适合直接作为存储格式,需要先转换。会话格式升级时可以在值里保存一个自己的版本号,读取旧格式后迁移。
存储当前落在应用数据目录的 PluginData/<插件 id>.json,不是 Keychain。账号插件应优先保存可恢复的会话凭据,并在退出时清理相应键。移除插件会保留它的登录信息和设置,重新安装仍可读取。
加载时宿主先执行文件、读取并验证清单,随后才配置网络权限、插件信息和持久化存储,再应用设置。因此模块顶层适合声明常量、函数和导出对象;不要在顶层发请求、读取会话作为固定快照,或依赖尚未就绪的 starry.plugin / starry.settings。
把会话恢复放在 account.current / refresh 或需要时读取存储。重新加载会创建新的运行时,内存缓存和定时器状态不能作为持久化机制。starry.setWebPages(pages) 也不跨启动保存,恢复会话后需要重新设置,传 null 则恢复 source.webPages 的默认模板。
在顶层导出 account,用 methods 声明实际支持的登录方式,并实现对应函数:
methods 值 |
函数 | 返回值与说明 |
|---|---|---|
password |
loginWithPassword(username, password) |
成功后保存会话并返回 Profile;允许空密码时声明 passwordOptional: true |
cookie |
loginWithCookie(text) |
校验输入、保存有效 Cookie 并返回 Profile;cookieHint 解释要粘贴什么 |
phoneCode |
sendPhoneCode(phone, countryCode)、loginWithPhoneCode(phone, countryCode, code) |
发送验证码返回 void,验证成功返回 Profile |
qrCode |
beginQRLogin(kind?)、pollQRLogin(session) |
开始返回 { key, url?, image?, kind? };轮询返回状态 |
code |
beginCodeLogin()、pollCodeLogin(session) |
在此处显示验证码,让用户去已登录设备输入;需要 codeLogin: { title, hint? } |
Profile 至少包含 { userID, nickname },可带 avatar、isVIP、detail。它描述用户,不包含登录令牌;令牌由插件保存并在后续请求中携带。
扫码轮询返回 'waiting'、'scanned'、'expired' 或 { status: 'confirmed', profile }。设备验证码登录开始时返回 { key, code },轮询没有 'scanned' 状态。确认登录时必须先保存可用会话再返回成功。
每个账号插件都要实现:
refresh():读取本地会话并向服务端验证;从未登录或本地没有会话时返回null,会话过期时抛starry.error('loginExpired'),成功时返回资料。logout():按平台协议结束远端会话,并清理本地会话及与该用户相关的缓存。- 可选的
current():只用本地数据返回Profile或null,供刷新前先显示资料,不发网络请求。
搜索、收藏、解析播放地址等函数也要通过同一套会话读取逻辑取得认证信息,避免某些接口仍使用旧令牌。可以将会话读写、请求头和统一错误处理集中在 client.ts,如 Jellyfin 客户端实现。
声明 account.server: { placeholder: 'https://music.example.com' } 并实现 connect(address),登录界面就会先询问服务器地址。connect 应规范化地址、验证服务器、保存后续登录所需的地址,并返回 { address, name?, version?, methods? };methods 可缩小到该服务器实际启用的登录方式。
用户可以输入任意服务器地址时,权限通常需要 hosts: ['*']。应保留服务器部署所需的路径前缀,不能一律截成域名。公共的地址处理工具见 plugins/common/server.ts,完整流程见 Jellyfin 账号实现。
声明 multipleAccounts: true,并实现 exportCredentials()、restoreCredentials(credentials)、signOutLocally():
- 导出的凭据必须是可序列化、足以恢复当前账号的快照;多服务器账号还需要包含服务器地址及身份信息。
restoreCredentials恢复会话、更新相关客户端状态并返回资料。signOutLocally只清理本地当前会话,不能注销远端令牌,否则切回时凭据已失效;用户真正退出账号才走logout。- 切换时清理用户相关的缓存,包括收藏、歌单、服务器网页模板和显式维护的 Cookie,避免混用上一账号的数据。
搜索和播放可用后,再按平台能力逐项扩展。详细返回类型见 SDK,现成实现见 网易云、Jellyfin 和 Subsonic。
| 功能 | 主要接口或声明 | 接入要点 |
|---|---|---|
| 批量歌曲详情 | songs(ids) |
返回 Track[],用于根据持久化 ID 再次取详情 |
| 专辑、歌手、歌单页 | album、artist、playlist |
分别返回 AlbumDetail、ArtistDetail、PlaylistDetail,不是裸数组 |
| 歌手分页内容 | artistSongs、artistAlbums、similarArtists |
artistSongOrders 声明支持 hot / time 排序 |
| 综合搜索 | searchOverview |
返回最佳匹配及各类前几项;不实现时由宿主组合分类搜索 |
| 搜索辅助 | searchSuggestions、trendingSearches、searchHints |
按前缀建议、热搜、搜索提示分别提供 |
| 首页内容 | homeShelves、recommendedPlaylists、newSongs、newAlbums、topArtists |
每个自定义货架只能含一种数据:歌曲、专辑、歌手或歌单;空货架不显示;recommendedPlaylists(true) 表示用户主动刷新,应绕过推荐缓存 |
| 喜欢和收藏 | likedTrackIDs、setLiked、setCollected |
喜欢的歌曲按最新在前返回;收藏类型由 collectableKinds 声明 |
| 用户歌单 | userPlaylists、likedPlaylistID |
自己创建的列表标 isOwned,有喜欢歌单时将其放在前面 |
| 歌单编辑 | createPlaylist、editPlaylist、deletePlaylist、addToPlaylist、removeFromPlaylist、reorderPlaylist |
playlistOptions 声明描述、隐私、名字长度等能力 |
| 每日推荐与电台 | dailyRecommendations、dailyPlaylists、personalFM、skipFM、trashFM |
personalFM 每次返回少量歌曲,注意 firstFetch 的新会话语义 |
| 风格推荐 | recommendationStyles、styleRecommendations、setRecommendationStyles |
三个接口都导出时,在每日推荐中显示风格入口;分类返回 styles 和 selectedStyleIDs;应用后重新读取配置,以平台返回的选中项为准 |
| 历史日推 | dailyRecommendationDates、dailyRecommendationHistory |
两个接口都导出时显示历史日推入口;日期使用服务端提供的 YYYY-MM-DD 字符串,最新在前,详情返回 Track[] |
| 全部歌曲 | allMedia(page) |
可返回数组或 { songs, total, hasMore, nextOffset };过滤后要正确推进原始分页位置 |
| 资料库网格 | libraryAlbums、libraryArtists、libraryGenres |
适合自建曲库;流派页要求同时有专辑页,albumSorts 第一项为默认排序 |
| 用户主页 | user、playlistsOfUser、listeningRanking、follows、followers、setUserFollowed |
先实现 user,关注列表能力需要 follows 和 followers;隐私状态用专门错误码 |
| 评论 | comments 或 commentThread,以及 replies、setCommentLiked |
游标接口结束时 next: null;点赞还需 canLikeComments: true,排序由 commentSorts 声明 |
| 听歌上报 | reportPlayback(report) |
playedSeconds / duration 为秒,startedAt / endedAt 为 Unix 毫秒;按平台规则判断是否记一次播放 |
| 打开平台网页 | webPages |
模板中使用 {id},可配置歌曲、专辑、歌手、歌单、用户链接 |
歌单编辑有额外的返回约定:createPlaylist 返回创建后的空歌单,addToPlaylist 跳过已存在的歌曲并返回实际新增数,removeFromPlaylist 删除指定歌曲的所有条目。reorderPlaylist 接收完整顺序,如果服务器里同时新增了不在参数中的歌曲,应保留并放在末尾;详情里的 pendingTrackIDs 也属于需要考虑的歌曲 ID。
不要把所有分页方法都视为一种结构:搜索与 allMedia 支持 nextOffset,资料库专辑 / 歌手分页使用各自数组及 total / hasMore,评论则可以使用游标。按各方法的类型实现终止条件。
starry.app 提供播放器版本、macOS 版本、架构、机型、设备名及可播放格式 formats。格式兼容性应优先根据它判断;Jellyfin 播放实现 展示了原始格式与转码选择。
服务器边生成边发送、没有内容长度且不支持 Range 的转码流,在 resolve 结果中返回 transcoded: true;播放器会先下载完整文件再播放,使后续 seek 可用。
ReplayGain 数据放在 gain: { trackGain?, trackPeak?, albumGain?, albumPeak? }。增益是达到 −18 LUFS 所需的 dB,峰值以 1 为满幅;不知道就省略,不要填估计值。空间音频档位可声明 spatial: true,此类播放由系统渲染,对应均衡器、频谱和唱歌模式不会启用。
resolve 返回 decrypt 参数,并提供 source.decryptor(params)。后者返回 (bytes: Uint8Array, offset: number) => void,原地修改每块音频数据;offset 是这块数据在文件中的绝对字节位置,不是固定块序号。
解密器在单独的插件副本中同步运行,不能依赖主运行时的网络、存储、定时器或内存会话。所需密钥等参数都应通过 decrypt 传入,并保证分块大小变化时仍然正确。播放器边下载边调用解密器,保存的是明文音频。
starry.encoding 提供 UTF-8、hex、base64;starry.crypto 提供 MD5、SHA 系列、HMAC、AES、DES、3DES、RSA 公钥加密和随机字节;starry.zlib 提供压缩、解压。具体参数见 SDK 的 Starry、CipherOptions。
字符串输入按 UTF-8 解释,十六进制或 base64 密钥需要先解码成字节。AES-GCM 的输入 / 输出末尾包含 16 字节认证标签;zlib.inflate 能按头识别 zlib、gzip 或 raw deflate。不要因为 Node 测试里有 Buffer / node:crypto,就在生产插件里直接导入它们。
- 在插件工程中运行
npm run watch,确认输出文件已生成。 - 在高级设置中添加输出
.js或它所在的目录。 - 修改代码,等待构建完成,点击插件页“重新加载”。
- 在播放器中触发搜索、播放、歌词或登录流程,观察结果。
- 需要断点时打开“允许调试插件”,在 Safari 的“开发”菜单选择本机下的“插件:<名称>”。
“记录插件调用”会把参数、耗时、结果写入系统日志,并在从终端启动播放器时打印到 stderr。可以查看日志:
log stream --predicate 'subsystem == "moe.mrs4s.starry-player" AND category == "plugin"'记录可能包含登录参数和响应中的会话信息,分享日志前应去除凭据。普通 console.log 适合记录阶段、条数、状态码;避免输出密码、Cookie 或令牌。
用 throw starry.error(code, message?) 表达可识别的业务状态:
| 错误码 | 含义 |
|---|---|
vipRequired、trialOnly |
需要会员、只有试听 |
loginExpired |
需要重新登录 |
unavailableInRegion、sourceUnreachable |
地区限制、来源不可达 |
notSupported |
该操作或资源不支持 |
network、timeout、rateLimited |
网络失败、超时、限流 |
rankingHidden、followsHidden |
用户未公开排行、关注 / 粉丝列表 |
其他异常会显示插件名和错误信息,可用简短中文说明具体失败原因。不要吞掉所有异常并返回空列表,否则界面只会像“没有内容”。
普通调用默认最多 30 秒;扫码和设备验证码轮询为 100 秒;同步执行的默认 CPU 时间限制为 5 秒,宿主会尽力中止超限代码,但不能将其视为所有死循环都能被强制终止的保证。HTTP 请求默认超时为 15 秒。避免无限轮询、无界重试和同步处理超大数据;轮询函数返回当前状态,由宿主安排后续调用。
| 现象 | 优先检查 |
|---|---|
| 加载列表没有插件 | 是否添加了真正的 .js 文件;目录扫描不递归;是否已经重新加载 |
| 修改没有生效 | 构建是否完成;加载的是 dist 还是旧产物;是否有同 ID 的开发副本覆盖安装副本 |
| “缺少 resolve” / “需要 search 和 fetch” | 对应分组必需函数是否存在;是否正确导出 module.exports / default |
require is not defined / Buffer is not defined |
是否忘记 bundle,或使用了 Node 专用依赖 |
hostNotAllowed |
清单是否包含请求主机及跳转后的主机;顶层是否过早发起请求 |
| “返回了无法识别的结果” | 是否返回了正确的对象层级;ID、数组、日期与数字类型是否符合 SDK |
| 搜索结果重复 / 提前结束 | offset、limit、hasMore、过滤后的 nextOffset 是否正确 |
| 搜得到但播不了 | resolve 是否能仅凭歌曲 ID 工作;URL 是否过期,下载请求头、容器是否正确 |
| 歌词时间错位 | 音源时长是否误用毫秒;歌词格式、时间单位、歌曲版本是否正确 |
| 重启后未登录 | 是否只存了内存变量;是否在顶层过早读取持久化状态;是否实现会话恢复 |
| 功能入口不出现 | 函数和附加声明是否齐全,如 collectableKinds、searchKinds、user |
类型检查只能发现接口形状问题,下面的测试直接执行打包产物,验证单位换算、分页参数、ID 编码、播放地址和 HTTP 错误映射。它不会访问真实服务。
在第 3 节的工程创建 test/plugin.test.cjs:
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const code = readFileSync(path.join(__dirname, '../dist/tutorial.js'), 'utf8');
function loadPlugin(get) {
const context = vm.createContext({
module: { exports: {} },
starry: {
http: { get },
error: (code, message = code) => Object.assign(new Error(message), { code }),
},
});
vm.runInContext(code, context);
return context.module.exports.default ?? context.module.exports;
}
test('搜索映射时长和 ID,并保留服务端分页位置', async () => {
const plugin = loadPlugin(async (url, options) => {
assert.equal(url, 'https://api.example.com/songs');
assert.equal(options.query.q, '星光');
assert.equal(options.query.offset, 20);
assert.equal(options.query.limit, 20);
return { status: 200, body: {
items: [{ id: 42, title: '星光', artist: { id: 7, name: '示例歌手' }, durationMs: 180000 }],
total: 41, hasMore: true, nextOffset: 40,
} };
});
const result = await plugin.source.search('星光', 'song', { offset: 20, limit: 20 });
assert.equal(result.songs[0].id, '42');
assert.equal(result.songs[0].artists[0].id, '7');
assert.equal(result.songs[0].duration, 180);
assert.equal(result.hasMore, true);
assert.equal(result.nextOffset, 40);
});
test('播放根据 ID 获取新地址', async () => {
const plugin = loadPlugin(async (url, options) => {
assert.equal(url, 'https://api.example.com/songs/a%2Fb/stream');
assert.equal(options.query.quality, 'standard');
return { status: 200, body: { url: 'https://media.example.com/test.mp3', expiresIn: 600 } };
});
const asset = await plugin.source.resolve(
{ id: 'a/b', title: '星光', duration: 180 },
{ id: 'standard', name: '标准', level: 'sq' },
);
assert.equal(asset.url, 'https://media.example.com/test.mp3');
assert.equal(asset.expiresIn, 600);
assert.equal(asset.tier, 'standard');
});
test('会话失效不伪装成空搜索结果', async () => {
const plugin = loadPlugin(async () => ({ status: 401, body: {} }));
await assert.rejects(
plugin.source.search('星光', 'song', { offset: 0, limit: 20 }),
{ code: 'loginExpired' },
);
});运行:
npm run check
npm test这套模拟只实现示例用到的宿主方法,不验证 JavaScriptCore 兼容性、网络权限、Cookie 或播放器 UI,仍需按上一节在播放器中联调。实际项目应继续覆盖空结果、过滤后的分页、歌词缓存未命中、过期凭据和账号切换等边界。
Jellyfin 和 Subsonic 的 Node 测试可作为更完整的参考。共用的 starry-mock.ts 提供模拟服务器、存储和部分密码算法;它不是完整宿主,例如当前主要提供 http.request,使用 get / post 等方法时需要补充相应模拟。
在仓库根目录运行宿主测试:
scripts/test.sh --filter PluginHostTests脚本先构建内置插件,再运行 Swift 测试。新插件不会仅因放入 plugins/ 自动成为内置插件或被测试加载;需要为它增加相应测试。宿主测试可通过 PluginTestSupport.swift 的 Fixture、StubProtocol 模拟 HTTP,验证真实 JavaScriptCore、序列化及 Swift 适配行为。
已有 LRCLIB / Audius 示例的联网测试需显式开启,在仓库根目录执行:
cd Packages/StarryKit
PLUGINS_LIVE=1 swift test --filter ExamplePluginLiveJellyfin、Subsonic 的本地服务器与联网测试步骤见各自 README。联网测试受外部服务状态影响,应与可重复的离线测试分开执行。
在插件工程中先执行 npm run check 和测试,再用 npm run build 生成压缩产物。分发 dist/tutorial.js 即可,不需要分发 node_modules 或让用户安装 npm。保留源码和锁文件,便于复现构建。
文件应为 UTF-8 文本,通过文件或网址安装时最大为 10 MiB。从网址安装要提供直接返回 JavaScript 文件内容的 HTTP(S) 链接,不是代码托管网站的 HTML 页面。更新插件导出对象中的 version,不要只修改 package.json。
用户可以在 设置 › 插件 中选择“从文件安装”或“从网址安装”,也可以将 .js 放到:
~/Library/Application Support/moe.mrs4s.starry-player/Plugins/
然后点击“重新加载”。从网址安装会记录该地址,之后可以在插件页触发更新;homepage 只是介绍链接,不是更新地址。
同一个插件 ID 的优先级为:开发路径 > 已安装插件 > App 自带插件。这不是按版本号选择最新版;开发路径里残留的旧副本也会覆盖已安装的新版本。发布验证时移除对应开发路径,再按普通安装流程检查一次。
仓库内置插件由 scripts/build-plugins.sh 的 PLUGINS 列表显式构建,输出 build/plugins/<名称>.js,App 构建时复制到 Resources/builtin。新建插件目录并不会自动加入列表。该脚本每次会重建整个 build/plugins/,教程工程使用自己的 dist/,避免将手工产物放进这个会被清空的目录。
id 决定插件身份与存储文件;随意改名会让宿主把它视为另一插件。修改展示名使用 name,升级使用 version。
idNamespace 则表示歌曲等资源沿用一个已知平台的 ID,只接受 netease、qqmusic、kugou。它会接管该平台对应的来源身份、已保存歌曲和相关设置 / 歌词匹配,因此只能在 ID 确实属于该平台时声明。
同一平台可以各有一个音源和一个歌词源,来自一个插件或两个不同插件。只导出 lyrics 的插件声明平台时仅接管歌词,设置仍属于该插件自己;重复的平台角色会被判定冲突。开发或已安装的插件可按加载顺序替代自带平台实现,不能靠随意填写 idNamespace 新增一个任意平台名。
- 在干净的安装流程中加载最终
.js,确认没有遗漏依赖、缺失清单或重复身份。 - 搜索第一页、下一页、空结果和播放成功;签名地址失效后能重新解析。
- 歌词没有结果时正常回退,有结果时时间单位正确。
- 带账号的插件验证登录、重启恢复、退出、过期及多账号切换。
- 权限包含真实请求和重定向主机,发布文件和日志中没有个人凭据。
- README 写清支持的服务、登录方式、限制和兼容版本,并提供单文件下载链接。
| 路径 | 适合参考的内容 |
|---|---|
| SDK 类型定义 | 所有字段、签名、单位和返回类型 |
| LRCLIB 示例 | 单文件歌词源、搜索缓存与按 ID 回源 |
| Audius 示例 | 单文件音源、搜索分类、歌手和歌单详情 |
| 网易云插件 | 音源、账号、用户主页、歌词与听歌上报 |
| Jellyfin 插件 | 自建服务器、多账号、快速连接、转码选择 |
| Subsonic 插件 | Subsonic / Navidrome 曲库、接口兼容与歌词 |
| QQ 音乐歌词插件、酷狗歌词插件 | 歌词格式与解密流程 |
| 服务器共用模块 | 地址处理、分页辅助、歌词转换、模拟测试 |
| PluginHost 实现 | 加载校验、HTTP 权限、运行时和 Swift 适配 |
| 项目开发文档 | App 构建、测试及打包 |