feat(source): 外部音源迁 src/ 并新增 source-simple 进程内直载

- apis/source(.ivm).ts → src/source(.ivm).ts(音源属服务端职责,不入 SDK)
- 新增 src/source-simple.ts:sources/*.mjs 进程内直载(getMusicUrl 极简契约),
  与 *.js 的 IVM 沙箱(globalThis.lx 契约)按扩展名混排候选链,失败降权冷却
- server 挂载决策:SOURCE_ENABLED 非 0/false 启用,/url 取流分流到外部音源;
  音源模式下登录态不参与取流,自动续期不启动(手动 /login/refresh 仍可用)
- 新增 src/env.ts(.env 最先加载)与 test/source-simple-test.ts(并入 pnpm test)
This commit is contained in:
snowy-yang committed 2026-10-01 16:35:08 +08:00
1 parent 66d06ef48a
commit 96f6a7fe91
13 files changed
+570 -94

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
# 外部音源 —— 简单 API 脚本(.mjs)
社区音源脚本之外的第二种脚本接入:脚本就是普通 Node ES 模块,**只需导出一个 `getMusicUrl` 函数**,无需 preload API 契约(`lx.on` / `lx.request` / crypto 桥接…)。适合自己写的取流脚本。
## 两种脚本对比
| | 社区音源脚本(`.js`) | 简单 API 脚本(`.mjs`) |
|---|---|---|
| 加载器 | `source-ivm.ts`,isolated-vm 硬隔离沙箱 | `source-simple.ts`,进程内直接 `import` |
| 脚本契约 | `lx.on('request')` + `lx.send('inited')` 全套 preload API | 一个 `getMusicUrl` 函数 |
| 能力 | 沙箱内桥接的 http/crypto/zlib | Node 全部能力(fetch / import 任意包) |
| 适用 | 来源不明的第三方混淆脚本 | 自己写、自己审计过的脚本 |
两种脚本都放 `sources/` 目录(不入库),按**文件名混排**成候选链(顺序即优先级),失败自动切换下一个。
## 脚本契约
```js
// sources/99-my-api.mjs
export const sources = ['kw', 'kg', 'tx', 'wy'] // 可选,默认 kw/kg/tx/wy 四平台
export const timeout = 15000 // 可选,单次取流超时 ms(默认 15000)
export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
// ...你的取流逻辑
return 'https://…' // 播放地址字符串,或 { type, url }
}
```
`getMusicUrl` 参数:
| 参数 | 说明 |
|---|---|
| `source` | 平台 id:`kw` / `kg` / `tx` / `wy`(或脚本自己声明的) |
| `id` | 歌曲 id(已归一:`songmid` / `hash` / `copyrightId` 取第一个非空值) |
| `songInfo` | 原始歌曲信息对象(含 songmid/hash 等原始字段) |
| `quality` | 音质:`128k` / `320k` / `flac` / `flac24bit` |
| `signal` | 取消/超时信号(`AbortSignal`)——调用方取消或超时都会触发;`fetch(url, { signal })` 透传即可支持取消 |
返回值:
- **字符串**:播放地址(最简单)
- **`{ type, url }`**:对象(type 为实际音质,用于音质回退标注)
抛错即本次取流失败,由候选链切换下一候选,不影响服务器。
## 示例
### 最小示例
```js
// sources/99-demo.mjs
export async function getMusicUrl({ source, id, quality }) {
return `https://your-api.example/get?source=${source}&id=${id}&quality=${quality}`
}
```
### 调用 JSON API 并透传取消信号
```js
// sources/98-my-api.mjs
const API = 'https://your-api.example'
export async function getMusicUrl({ source, id, quality, signal }) {
const res = await fetch(`${API}/url?source=${source}&id=${id}&quality=${quality}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { code, msg, data } = await res.json()
if (code !== 0) throw new Error(msg || `code=${code}`)
return data.url
}
```
### 按平台区分逻辑
```js
// sources/97-multi.mjs
export const sources = ['kw', 'kg'] // 只接入 kw/kg,其余平台走候选链中的其它脚本
export async function getMusicUrl({ source, id, quality }) {
if (source === 'kw') return kwLogic(id, quality)
return kgLogic(id, quality)
}
```
## 候选链行为
- 顺序 = 文件名排序(`.js` 与 `.mjs` 混排),如 `01-api.js` 优先于 `99-my-api.mjs`
- 某脚本的平台连续失败 **3 次** → 该脚本在该平台降权冷却 **5 分钟**(沉到候选末尾,恢复后自动探测)
- 单脚本无处可切时不做降权,行为与直连失败一致(路由 502)
- `SOURCE_SCRIPT_PATH` 指定单个社区脚本(`.js`)时跳过目录扫描,但 `.mjs` 仍参与候选链
## 启用方式与调试
- 环境变量 `SOURCE_ENABLED=1`(任意非 `0`/`false` 值)启用外部音源;不设置则走内置直连。音源模式下登录态不参与取流,登录自动续期不会启动
- `pnpm dev` 热重载已监听 `sources/` 目录,改脚本保存即自动重启生效
- 日志前缀 `[source-simple]`:加载(平台列表)、每次取流(▶ 开始 / ✔ 完成 / ✖ 失败,含耗时)
- 离线测试:`node test/source-simple-test.ts`(已并入 `pnpm test`)
## 边界
- **无沙箱**:脚本运行在服务器进程内,可访问 fs/process 等——只放自己审计过的代码;不可信脚本用 `.js` 走 [isolated-vm 加载器](source-ivm.md)
- 仅接入**取流**(getMusicUrl);歌词、封面、榜单等能力始终走内置实现
- 脚本内部自起的长任务(如忽略 signal 的死循环请求)由超时兜底放弃等待,脚本侧仍可能继续在后台运行