写给要写插件的实现者。读完这份文档,你应该能回答三个问题:哪些接口必须实现、哪些可选、每个接口的入参和返回值到底长什么样。
插件格式沿用 MusicFree 的约定:一个 CommonJS 模块,导出插件对象。听屿只覆盖其中一部分接口,本文档写的是听屿宿主真实调用的那部分,不是 MusicFree 的全集。
- 登录与续期单独一份:plugin-login-spec.md
- 仓库里的参考实现:
tingyu/tingyu-netease.js(功能最全)、third-party/bilibili.js(只做搜索和播放)
const axios = require('axios');
async function search(keyword, page, type) {
const res = await axios.get('https://api.example.com/search', {
params: { q: keyword, page, type }
});
const list = res.data.list || [];
return {
isEnd: list.length === 0,
data: list.map(item => ({
id: String(item.id),
title: item.name,
artist: item.singer,
album: item.album || '',
artwork: item.cover,
duration: item.duration, // 秒
type: 'music'
}))
};
}
async function getMediaSource(song, quality) {
const res = await axios.get('https://api.example.com/play', {
params: { id: song.id, quality }
});
return { url: res.data.url };
}
module.exports = {
platform: '我的音源',
version: '1.0.0',
primaryKey: ['id'],
supportedSearchType: ['music'],
search,
getMediaSource
};这两个方法就能装进听屿、搜索、播放。其余能力按需加,不实现的能力在界面上不会出现。
插件源码被包进 CommonJS 包装器求值:
(function(module, exports, require) { /* 你的源码 */ })(module, exports, require);所以两种导出都行:module.exports = {...} 或 exports.x = ...。module.exports 必须是对象,否则加载失败。
只有下面 8 个,其它名字一律抛 Unsupported module: xxx:
| require | 说明 |
|---|---|
axios |
宿主注入的网络桥,见 2.4 |
dayjs |
日期处理 |
he |
HTML 实体转义 |
crypto-js |
加密、哈希、编码 |
cheerio |
HTML 解析 |
webdav |
WebDAV 客户端 |
qs |
查询串序列化 |
big-integer |
大整数 |
Node 内置模块(fs、path、buffer、crypto 原生版)都不可用。需要哈希用 crypto-js,需要 Base64 用 btoa/atob。
实测结果(宿主注入 + 引擎自带):
| 存在 | 不存在 |
|---|---|
Promise、Symbol、JSON、Math、Date、RegExp |
setTimeout、setInterval、clearTimeout、queueMicrotask |
atob、btoa、URL、URLSearchParams |
TextDecoder、TextEncoder、crypto、structuredClone |
fetch、Headers、Response |
Request、XMLHttpRequest、FormData、Blob |
process(垫片)、env、console(静默) |
window、document、localStorage、Buffer、global |
没有定时器。任何 new Promise(r => setTimeout(r, 1000)) 都会永久挂起,整次调用在 60 秒后被强制终止(见 2.6)。需要退避就靠请求本身的失败重试,不要自己 sleep。
console.log 等全是空函数,打日志看不到东西。调试靠返回值。
支持这些调用形式:
axios({ url, method, params, headers, data, responseType })
axios.get(url, config)
axios.post(url, data, config)
axios.request(config) // 等同 axios(config)不支持 .put / .delete / .patch / .head、axios.create()、interceptors、defaults。需要 PUT/DELETE 时用 axios({ method: 'PUT', ... })。
config 认的字段:
| 字段 | 说明 |
|---|---|
url |
必填 |
method |
默认 GET |
params |
查询参数对象;数组会以逗号拼接 |
headers |
请求头,值必须是字符串 |
data |
请求体;字符串原样发,对象按 JSON 序列化(GET 时忽略) |
responseType |
只有 'arraybuffer' 有特殊处理,res.data 变成字节数组 |
成功时 res 长这样:
{ data, status, headers, config }data:响应体。JSON 能解析就解析成对象,否则是字符串。headers:全部小写键名不保证,按服务端给的来。
非 2xx 时 Promise 直接 reject,错误对象上带 error.response,结构和成功时一样:
try {
const res = await axios.get(url);
} catch (e) {
// e.response.status 非 2xx
// e.message 是 "HTTP 401" 这类文本
}没有 Cookie 罐。每次请求都是全新的,服务器种的 Cookie 不会自动带上。登录态必须自己拼 Cookie 请求头:
headers: { Cookie: env.userVariables.cookie }网络层有默认重试:超时、DNS 失败、连接被掐、TLS 握手失败、5xx、408、429 会自动再试,最多 3 次(首次 + 2 次重试),间隔约 0.4 秒和 0.9 秒。插件不需要自己实现重试。
注意重试对插件无感:插件看到的是最终结果,中间的重试不会让它多收到回调。
用户填的值从 env.userVariables 读,只读:
const token = (env.userVariables.token || '').trim();
// 或
const all = env.getUserVariables();插件不能修改、也不需要持久化任何东西。写回由宿主负责。
- 一个插件实例只有一个 JS 上下文,所有调用串行执行。同一插件的两次
search不会并行。 - 单次调用上限 60 秒(包含其中的所有 HTTP)。超时后运行时进入不可用状态,必须重新加载插件才能继续,界面上会提示。
- 因此不要写死循环等待、不要用定时器轮询、不要在一次调用里串太多请求。
用户切歌、搜索翻页、退出页面时,宿主会取消正在进行的调用。此时插件里的 axios Promise 会 reject,文本是 Plugin request cancelled.。这是正常的,不要 try/catch 掉之后继续发请求——取消之后发出的请求不会被执行。
宿主加载插件时会校验:
platform必须是非空字符串- 至少实现下表 14 个公开能力中的一个
不满足任一条,插件装不上。
| 字段 | 必需 | 宿主用途 |
|---|---|---|
platform |
是 | 平台名,插件列表与来源筛选显示 |
version |
建议 | 版本号,展示与升级比较 |
name |
否 | 目录名兜底 |
author |
否 | 插件详情展示 |
srcUrl |
否 | 宿主当前不读,写了不报错 |
description |
否 | 宿主当前不读,写了不报错 |
supportedSearchType |
建议 | 声明哪些搜索类型能出结果,见 5.1 |
supportedQuality / supportedQualities |
否 | 音质档位,缺省为 low/standard/high/super |
artistWorkTypes / supportedArtistWorkTypes |
否 | 歌手页支持哪些作品类别(music / album) |
userVariables |
否 | 用户变量表单定义,见第 8 节 |
login |
否 | 扫码登录配置,见登录规范 |
cacheControl |
否 | 直链缓存策略:cache / no-cache(默认)/ no-store |
primaryKey |
否 | 兼容字段,宿主当前不读 |
appVersion、website、defaultSearchType、hints |
否 | MusicFree 兼容字段,宿主当前不读,写了不报错 |
宿主按方法是否存在判断能力。没实现的方法对应的入口不会出现,不会报错。
| 接口 | 级别 | 不实现的后果 |
|---|---|---|
search |
必需 | 搜不到任何东西,插件基本没用 |
getMediaSource |
必需 | 能搜到但播不了 |
getLyric |
强烈建议 | 歌词页空白 |
getMusicInfo |
强烈建议 | 播放时补不到完整信息(封面、时长、专辑) |
getMusicSheetInfo |
可选 | 歌单点不进去 |
getAlbumInfo |
可选 | 专辑点不进去 |
getArtistWorks |
可选 | 歌手页点不进去 |
getTopLists |
可选 | 发现页没有排行榜 |
getTopListDetail |
可选 | 榜单只有列表没有曲目 |
getRecommendSheetTags |
可选 | 推荐歌单没有分类 |
getRecommendSheetsByTag |
可选 | 发现页没有推荐歌单 |
getRecommendWorks |
可选 | 首页「为你推荐」里没有口味推荐 |
getDailySongs |
可选 | 首页「为你推荐」里没有每日推荐 |
getMusicComments |
可选 | 播放页没有评论入口 |
importMusicItem |
可选 | 不能粘贴单曲链接导入 |
importMusicSheet |
可选 | 不能粘贴歌单链接导入 |
createLoginQRCode / checkLoginQRCode / loginStatus |
可选(三件套) | 不能扫码登录 |
refreshLogin |
可选 | 登录态过期只能重新扫码 |
同族接口要成对实现:getTopLists 配 getTopListDetail,getRecommendSheetTags 配 getRecommendSheetsByTag,登录要三个一起。缺一个就是半截功能。
通用约定:
- 所有方法都可以是
async,宿主拿到的返回值会被 JSON 序列化。返回undefined、函数、循环引用都会导致调用失败。 page从 1 开始。- 参数里的
item就是你之前返回给宿主的那个对象原样传回(宿主会保留全部字段)。所以自定义字段(比如songId、bvid)可以放心存在item里,后续从item.xxx取。
search(keyword, page, type) -> { isEnd, data: [item, ...] }| 参数 | 说明 |
|---|---|
keyword |
用户输入的关键词 |
page |
页码,从 1 开始 |
type |
music / album / artist / sheet / lyric |
返回:
| 字段 | 说明 |
|---|---|
data |
结果数组,元素结构见第 6 节 |
isEnd |
是否最后一页。不传时宿主按 data 为空判断 |
supportedSearchType 决定哪些类型能出结果。搜索页的五个页签是固定的(歌曲、专辑、歌手、歌单、歌词),用户点到插件没声明的类型时,宿主不会发请求,结果是空列表。
插件声明了但 search 里没处理这个 type,那个页签同样是空的,不报错。这是最容易踩的坑,见第 10 节。
type: 'sheet' 有个兼容:插件在 supportedSearchType 里写 playlist 也认,宿主会自动换过去。
getMediaSource(song, quality) -> { url, headers?, userAgent?, preview? }| 返回字段 | 说明 |
|---|---|
url |
必填。可直接播放的直链(http/https) |
headers |
可选。拉流时要带的请求头,比如 Referer、Cookie |
userAgent |
可选。单独指定 UA,等价于在 headers 里放 User-Agent |
preview |
可选。true 表示这只是试听片段,宿主会提示用户且不写入缓存 |
quality 取值 low / standard / high / super。插件用 supportedQuality 声明支持哪些档位,用户切换时宿主传对应值。不支持某档位时返回该档位能拿到的最高质量,不要抛错——否则切档位就是播放失败。
拿不到任何地址时必须抛错,宿主会把这次失败当解析失败处理并重试;返回 {url: ''} 不会被当成成功。
getLyric(song) -> { rawLrc, translation? } | string返回对象时,宿主依次找 rawLrc、rawLrcTxt、lrc、lyric 四个键,取第一个非空的。也可以直接返回 LRC 文本字符串。没有歌词返回 null 或空字符串。
LRC 格式照常:[mm:ss.xx]歌词。带翻译的用 translation 字段,同样是 LRC 文本。
getMusicInfo(item) -> item播放开始后宿主拿搜索结果里的 item 调一次,用来补全正在播放的这一首:标题、歌手、封面。返回能拿到的字段就行,缺的字段保留原值,不必回传完整对象。
三条宿主行为要注意:
- 返回的
artwork非空时会替换封面,并写回队列项落盘,下次冷启动不用再请求 - 返回的
url会被忽略,不会覆盖已经解析好的音源 - 只影响正在播放的那一首,列表里的条目仍然是搜索时的样子
getAlbumInfo(item, page) -> { isEnd?, musicList, albumItem? }
getMusicSheetInfo(item, page) -> { isEnd?, musicList, sheetItem? }| 字段 | 说明 |
|---|---|
musicList |
曲目数组。也可以用 data 代替 |
isEnd |
是否最后一页,缺省 true |
albumItem / sheetItem |
可选。集合自身的元信息(标题、封面、简介),会合并到页面头部 |
albumItem / sheetItem 里宿主认的字段:title(标题)、artwork 或 cover(封面)、description(简介)。
item 就是用户点进来的那个专辑/歌单对象,里面有搜索时返回的 id 等字段。
getArtistWorks(item, page, type) -> { isEnd?, musicList }type 是 music(歌曲)或 album(专辑)。插件用 artistWorkTypes: ['music', 'album'] 声明支持哪些;不声明时宿主只按 music 调用。
返回专辑列表时,musicList 里的元素是专辑对象(第 6.2 节),用户点进去会再走 getAlbumInfo。
getTopLists() -> [ { title, data: [chartItem, ...] }, ... ]
getTopListDetail(item, page) -> { isEnd?, musicList | data }getTopLists 返回分组数组。每个分组 { title: '分组名', data: [...] },data 里是榜单条目,条目至少要有 title 和一个能区分身份的 id。
getTopListDetail 收的是用户点的那个榜单条目,返回曲目。
getRecommendSheetTags() -> { pinned?: [tag], data: [ { title, data: [tag] } ] }
getRecommendSheetsByTag(tag, page) -> { isEnd, data: [sheetItem, ...] }分类结构是「置顶 + 分组」:pinned 是置顶分类数组,data 是分组数组,每组的 data 是分类条目。分类条目至少要有 id 或 title。
用户没选分类时,宿主会取 pinned[0] 或第一组的第一个分类去调 getRecommendSheetsByTag。没实现 getRecommendSheetTags 的话,宿主传一个空对象 {} 进去,插件自己兜底默认分类。
getRecommendWorks() -> { isEnd, data: [musicItem, ...] } // 按口味推荐,通常需要登录
getDailySongs() -> { isEnd, data: [musicItem, ...] } // 每日推荐,通常需要登录这两个只影响首页推荐位。没登录时抛错即可,宿主会跳过。
getMusicComments(item, page) -> { isEnd, data: [comment, ...] }评论条目宿主认的字段:
| 字段 | 说明 |
|---|---|
id / commentId / uid |
唯一标识 |
nickName / username / nickname / user |
昵称 |
comment / content / message / text |
正文 |
time / createTime / date |
时间 |
like / likeCount / likedCount / likes |
点赞数 |
replies / reply |
子回复数组,结构同上 |
importMusicItem(text) -> musicItem
importMusicSheet(text) -> [musicItem, ...] | { musicList|data, title?, coverImg? }text 是用户粘贴的原始文本,可能是链接、短链、整段分享文案,插件自己从中提取 ID。
单曲导入必须返回非空对象。歌单导入可以只返回曲目数组(歌单名退回默认、封面退回第一首),也可以返回带 title 和 coverImg 的对象,宿主会用上这两个字段。
createLoginQRCode() -> { key, qrData }
checkLoginQRCode(key) -> { code, cookie? }
loginStatus(cookie) -> { nickname?, avatar? }qrData 是二维码里要编码的内容,通常是登录页 URL。key 是轮询凭证,宿主原样回传。
code 语义固定:800 过期、801 等待扫码、802 已扫码待确认、803 授权成功(此时必须给 cookie)。
cookie 是 k=v; k=v 请求头串,必须包含 login.sessionCookieKey 声明的字段。
完整约定见 plugin-login-spec.md。
refreshLogin() -> { cookie }用当前凭据换新凭据,不实现则宿主跳过续期。细节同样在登录规范里。
搜索、歌单、专辑、榜单、推荐返回的曲目都用这个结构。宿主认的字段:
| 字段 | 必需 | 说明 |
|---|---|---|
id |
是 | 平台内唯一 ID,播放时原样回传 |
title |
是 | 歌名。也认 name |
artist |
建议 | 歌手。也认 author |
artwork |
建议 | 封面 URL。也认 cover、avatar |
duration |
建议 | 时长,秒 |
album |
否 | 专辑名 |
type |
否 | music(默认)/ album / sheet / artist |
id 缺失时宿主会拿 title + artist 拼一个,翻页时容易重复,不建议省。
自定义字段(bvid、cid、songId……)都保留在 item 里传回,随便加。
| 字段 | 说明 |
|---|---|
id |
唯一 ID |
title |
名称。也认 name |
artwork |
封面。也认 cover |
artist |
歌手/作者 |
description |
简介 |
type |
album / sheet / artist |
importMusicSheet 返回对象时另有一套:title 和 coverImg(也认 cover、coverUrl、coverURL)。
所有列表接口统一 { data, isEnd }。isEnd: true 表示没有下一页。返回空数组等价于结束。
插件抛出的错误会变成用户看到的文案,所以错误文本本身就是产品文案,写人话:
if (!token) throw new Error('还没配置 API Token,去插件详情里填一下');
if (res.status === 401) throw new Error('接口令牌失效了,请更新插件配置');不要抛 Error: 500、TypeError: undefined 这类。宿主对两种情况有特殊处理:
- 错误文本含
getUserVariables/getUserVariable/userVariables时,转成「请检查插件详情中的配置项」 - 错误文本含
code=301时,视为登录态过期,先续期再重试(见登录规范)
userVariables 里声明了 required: true 的变量没填时,宿主在调用任何接口前就拦住并提示,插件不用自己校验。
userVariables: [
{
key: 'token', // 必填,env.userVariables 里的键名
name: 'API Token', // 表单里的标题
type: 'password', // text(默认)/ password
required: true, // 没填就拦住所有接口调用
hint: '接口访问令牌', // 输入框下方短提示
description: '自建接口开了认证,必须填写才能搜索和播放', // 详细说明
defaultValue: '' // 预填值
}
]type: 'password' 的值在界面上打码显示。变量存在宿主 Keychain 里,WebDAV 备份会带上它们。
装上插件后按顺序过一遍:
- 搜索:五个页签分别搜一次,返回的条目点得动
- 播放:点歌能出声,切到后台继续播
- 音质:切
standard/high,不报错且真的换了 - 翻页:一直翻到底,
isEnd生效,不重复不丢 - 歌词:有歌词的歌能滚,没歌词不卡住
- 集合:歌单、专辑、歌手各点进去一次,曲目能播
- 发现页:排行榜、推荐歌单、分类切换
- 导入:粘一条单曲链接、一条歌单链接
- 断网:断网后每个入口给出人话提示,不是英文异常
- 切歌:连续快速切十首,不卡死(说明没有阻塞式等待)
用了 setTimeout。 沙箱没有定时器,Promise 永远不 resolve,60 秒后运行时被终止,插件要重新加载。
以为 Cookie 会自动带上。 每请求独立,登录态必须放进 headers.Cookie。
headers 里塞了非字符串。 值必须是字符串,{ 'X-Count': 10 } 会让请求失败,要写 '10'。
getMediaSource 返回空 url 而不是抛错。 宿主当成功处理,然后播放失败,用户看到的是「播放失败」而不是可读原因。
声明了搜索类型但 search 里没分支处理。 那个页签永远空白,不报错,很难查。
把大对象塞进 item。 item 会跟着队列、播放历史、备份走一遍,塞整页 HTML 会让这些数据膨胀。
在 search 里同步阻塞。 一次调用最多 60 秒,串多个请求时留足余量,能合并的接口就合并。
require('fs')。 沙箱没有文件系统,需要本地数据就交给宿主(用户变量)。