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:
1 parent
66d06ef48a
commit
96f6a7fe91
13 files changed
+570
-94
No files matched your search
@@ -5,7 +5,6 @@ yarn.lock
|
||||
|
||||
# 音源脚本(第三方、不可审计,不入库;放入 sources/ 自动加载)
|
||||
sources/
|
||||
lx-music-source-v5.js
|
||||
|
||||
# Runtime
|
||||
*.log
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@
|
||||
## 架构
|
||||
|
||||
```
|
||||
调用方 ── source.kw.getMusicUrl(songInfo, type) ──▶ src/apis/source.js(懒加载单例)
|
||||
调用方 ── source.kw.getMusicUrl(songInfo, type) ──▶ src/source.ts(懒加载候选链)
|
||||
│
|
||||
▼
|
||||
src/apis/source-ivm.js
|
||||
src/source-ivm.ts
|
||||
isolated-vm 硬隔离沙箱
|
||||
┌──────────────────────────┐
|
||||
│ 音源脚本(升版即换文件) │
|
||||
|
||||
@@ -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 的死循环请求)由超时兜底放弃等待,脚本侧仍可能继续在后台运行
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"typecheck": "tsc",
|
||||
"start": "node src/server.ts",
|
||||
"dev": "node --watch-path=./src --watch-path=./sdk --watch-path=./sources --watch --watch-preserve-output src/server.ts",
|
||||
"test": "pnpm run typecheck && node test/smoke-test.ts"
|
||||
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.3.0",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// 外部聚合音源统一入口(isolated-vm 懒加载封装)
|
||||
// 内部委托 source-ivm.ts 在硬隔离沙箱中执行音源脚本。
|
||||
// 首次调用 getMusicUrl 时才加载并初始化隔离环境,后续复用同一实例。
|
||||
// 统一接口约定:source.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
|
||||
//
|
||||
// 脚本来源(优先级):
|
||||
// 1. 环境变量 SOURCE_SCRIPT_PATH 指定的脚本文件
|
||||
// 2. sources/ 目录下按文件名排序的第一个 .js(脚本文件不入库,见 .gitignore)
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createIvmSource } from './source-ivm.ts'
|
||||
|
||||
export const SOURCES = ['kw', 'kg', 'tx', 'wy']
|
||||
|
||||
const SOURCES_DIR = fileURLToPath(new URL('../../sources/', import.meta.url))
|
||||
|
||||
const resolveScriptPath = () => {
|
||||
const custom = process.env.SOURCE_SCRIPT_PATH
|
||||
if (custom) return custom
|
||||
if (!fs.existsSync(SOURCES_DIR)) {
|
||||
throw new Error(`音源目录不存在: ${SOURCES_DIR}(将脚本放入 sources/ 目录,或用 SOURCE_SCRIPT_PATH 指定)`)
|
||||
}
|
||||
const files = fs.readdirSync(SOURCES_DIR).filter(f => f.toLowerCase().endsWith('.js')).sort()
|
||||
if (!files.length) {
|
||||
throw new Error('sources/ 目录下没有 .js 音源脚本(将脚本放入该目录,或用 SOURCE_SCRIPT_PATH 指定)')
|
||||
}
|
||||
if (files.length > 1) {
|
||||
console.log(`[source] sources/ 下有 ${files.length} 个脚本,使用 ${files[0]}(可用 SOURCE_SCRIPT_PATH 指定)`)
|
||||
}
|
||||
return path.join(SOURCES_DIR, files[0])
|
||||
}
|
||||
|
||||
let sharedPromise: Promise<any> | null = null
|
||||
|
||||
const getShared = () => {
|
||||
if (!sharedPromise) {
|
||||
// 配置可经环境变量调整,与项目其它适配器保持一致风格
|
||||
sharedPromise = createIvmSource({
|
||||
scriptPath: resolveScriptPath(),
|
||||
log: process.env.SOURCE_LOG !== '0',
|
||||
allowAllHttp: process.env.SOURCE_ALLOW_ALL_HTTP === '1',
|
||||
}).catch(err => {
|
||||
sharedPromise = null // 失败允许下次重试
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return sharedPromise
|
||||
}
|
||||
|
||||
const apiFor = (source) => ({
|
||||
getMusicUrl(songInfo, type) {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
let inner: any = null
|
||||
requestObj.cancelHttp = () => inner?.cancelHttp()
|
||||
requestObj.promise = (async () => {
|
||||
const api = await getShared()
|
||||
if (!api[source]) throw new Error(`脚本不支持的平台: ${source}`)
|
||||
inner = api[source].getMusicUrl(songInfo, type)
|
||||
return inner.promise
|
||||
})()
|
||||
return requestObj
|
||||
},
|
||||
})
|
||||
|
||||
// 兼容导出:sdk/api-source.ts 以 createApi(source) 方式接入聚合音源
|
||||
export const createApi = apiFor
|
||||
|
||||
const sourceApi: Record<string, any> = {}
|
||||
for (const s of SOURCES) sourceApi[s] = apiFor(s)
|
||||
|
||||
// 预热:提前触发脚本加载与初始化(可选调用)
|
||||
sourceApi.init = getShared
|
||||
|
||||
export default sourceApi
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// .env 加载器(副作用模块):读取项目根目录 .env,已存在的环境变量优先于文件值。
|
||||
// 必须作为入口的第一个 import —— server.ts 在模块体内读取 SOURCE_ENABLED/PORT 等,
|
||||
// 而 ESM 静态 import 按声明顺序求值,晚于任何本模块代码。
|
||||
// .env 不入库(见 .gitignore),缺失时静默跳过。
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const envPath = join(dirname(fileURLToPath(import.meta.url)), '../.env')
|
||||
if (existsSync(envPath)) {
|
||||
try {
|
||||
process.loadEnvFile(envPath)
|
||||
} catch (err) {
|
||||
console.warn(`[env] .env 解析失败,已忽略: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
// 登录态自动续期调度 —— 启动即刷一轮 + 每 24h 一次(与 lx-music-api-server 的
|
||||
// APScheduler interval 86400s + next_run_time=now 对齐)
|
||||
// 登录态自动续期调度 —— 启动即刷一轮 + 每 24h 一次
|
||||
// 各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。
|
||||
|
||||
import { refreshLogin as kgRefreshLogin } from '../sdk/auth/kg.ts'
|
||||
|
||||
+6
-8
@@ -130,10 +130,12 @@ export const musicRoutes = async(fastify: any) => {
|
||||
const { source, id } = request.params
|
||||
const { quality, albumId } = request.query
|
||||
const m = getSource(musicSdk, source, 'getMusicUrl')
|
||||
// 音源模式下服务端把取流重定向到外部音源脚本;未挂载时走 SDK 官方直连接口
|
||||
const impl = fastify.sourceApi?.[source] ?? m
|
||||
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
||||
let result
|
||||
try {
|
||||
result = unwrap(m.getMusicUrl(songInfo, quality))
|
||||
result = unwrap(impl.getMusicUrl(songInfo, quality))
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
@@ -369,19 +371,15 @@ export const musicRoutes = async(fastify: any) => {
|
||||
fastify.get('/sources', {
|
||||
schema: {
|
||||
summary: '获取支持的平台',
|
||||
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。',
|
||||
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。' +
|
||||
'音质为 SDK 官方直连接口的支持范围;启用外部音源(SOURCE_ENABLED)时取流由 sources/ 脚本提供,实际可用音质以脚本为准。',
|
||||
tags: ['系统'],
|
||||
response: responses('SourcesResult', { notFoundDoc: false, upstreamDoc: false }),
|
||||
},
|
||||
}, async() => ({
|
||||
platforms: musicSdk.sources.map(({ id, name }) => {
|
||||
const m = musicSdk[id] || {}
|
||||
const qualities = [...new Set(
|
||||
Object.values(musicSdk.supportQuality || {})
|
||||
.map((q: any) => q[id])
|
||||
.filter(Boolean)
|
||||
.flat(),
|
||||
)]
|
||||
const qualities = musicSdk.supportQuality?.[id] ?? []
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
|
||||
+16
-2
@@ -19,6 +19,7 @@
|
||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||
// 响应信封:成功 { code: 0, data };失败 { code: <HTTP状态码>, msg, detail? }
|
||||
|
||||
import './env.ts' // 必须最先导入:在任何读取 process.env 的模块求值前加载 .env
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import swagger from '@fastify/swagger'
|
||||
@@ -27,6 +28,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import musicSdk from '../sdk/index.ts'
|
||||
import sourceApi from './source.ts'
|
||||
import { ApiError } from './http/api-error.ts'
|
||||
import { musicRoutes } from './routes.ts'
|
||||
import { loginRoutes } from './routes-login.ts'
|
||||
@@ -50,6 +52,12 @@ const fastify = Fastify({
|
||||
})
|
||||
fastify.decorate('musicSdk', musicSdk)
|
||||
|
||||
// ---- 音源模式(服务端职责):SOURCE_ENABLED 非 0/false 时,/url 取流重定向到 sources/ 外部音源脚本 ----
|
||||
// SDK 只包含官方平台接口;是否走外部音源由服务端在此决策,路由层按 sourceApi 是否挂载分流
|
||||
const sourceEnabled = process.env.SOURCE_ENABLED
|
||||
const useSource = Boolean(sourceEnabled) && sourceEnabled !== '0' && sourceEnabled !== 'false'
|
||||
fastify.decorate('sourceApi', useSource ? sourceApi : null)
|
||||
|
||||
// ---- 共享 schema(领域模型:校验引用与 OpenAPI components 同源) ----
|
||||
for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
|
||||
fastify.addSchema({ ...schema, $id: name })
|
||||
@@ -185,12 +193,18 @@ fastify.get('/', {
|
||||
fastify.register(musicRoutes)
|
||||
fastify.register(loginRoutes)
|
||||
|
||||
// 登录态自动续期(onClose 钩子需在 listen 前注册,故这里而非 startServer 内启动)
|
||||
startRefreshScheduler(fastify)
|
||||
// 登录态自动续期(onClose 钩子需在 listen 前注册,故这里而非 startServer 内启动)。
|
||||
// 音源模式下取流走外部脚本,登录态不参与任何接口,自动续期没有意义;手动 /login/refresh 仍可用
|
||||
if (!useSource) {
|
||||
startRefreshScheduler(fastify)
|
||||
} else {
|
||||
console.log(`[${new Date().toISOString()}] [登录续期] 音源模式已启用,登录态不参与取流,自动续期未启动`)
|
||||
}
|
||||
|
||||
export const startServer = async(port = PORT) => {
|
||||
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
|
||||
console.log(`音乐服务器已启动: http://localhost:${port}`)
|
||||
console.log(`取流通道: ${useSource ? '外部音源脚本(sources/,SOURCE_ENABLED)' : 'SDK 官方直连接口'}`)
|
||||
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
|
||||
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
|
||||
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 依赖:isolated-vm(未安装时工厂函数抛出明确错误)
|
||||
// 用法:
|
||||
// import { createIvmSource } from './source-ivm.ts'
|
||||
// const source = await createIvmSource({ scriptPath }) // scriptPath 必填(sources/ 扫描逻辑见 source.js)
|
||||
// const source = await createIvmSource({ scriptPath }) // scriptPath 必填(sources/ 扫描逻辑见 source.ts)
|
||||
// const source = await createIvmSource({ scriptPath, http }) // http 可注入 mock 做离线测试
|
||||
// await source.kw.getMusicUrl({ songmid: '123' }, '320k').promise
|
||||
// await source.destroy()
|
||||
@@ -29,7 +29,7 @@
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import zlib from 'node:zlib'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { httpFetch } from '../sdk/common/request.ts'
|
||||
|
||||
// 从脚本头部的 SERVER_SCRIPT_CONFIG 提取 apiUrl 声明,作为 HTTP 白名单缺省值。
|
||||
// 兼容裸键(apiUrl:)与 JSON 风格("apiUrl":),并处理 \/ 转义
|
||||
@@ -0,0 +1,97 @@
|
||||
// 简单 API 音源脚本加载器(.mjs,进程内直接 import,无沙箱)
|
||||
//
|
||||
// 社区音源脚本之外的第二种脚本接入:不要求 preload API 契约(lx.on / lx.request / crypto 桥接…),
|
||||
// 脚本就是普通 Node ES 模块,export 一个 getMusicUrl 即可,适合自己写的取流脚本。
|
||||
// 注意:与社区脚本(多为第三方混淆产物)不同,它运行在服务器进程内(无隔离),
|
||||
// 只放自己审计过的脚本;不可信脚本仍用 .js 走 source-ivm.ts 硬隔离。
|
||||
//
|
||||
// 脚本约定(sources/ 目录下的 .mjs 文件,与社区 .js 脚本并存,文件名混排即候选优先级):
|
||||
// export const sources = ['kw', 'kg', 'tx', 'wy'] // 可选,默认四平台
|
||||
// export const timeout = 15000 // 可选,单次取流超时 ms(默认 15000)
|
||||
// export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
|
||||
// // source: 平台 id;id: 歌曲 id(songmid/hash 归一);songInfo: 原始歌曲信息对象
|
||||
// // quality: 音质(128k/320k/flac/flac24bit)
|
||||
// // signal: 取消/超时信号(AbortSignal),fetch(url, { signal }) 透传即可支持取消
|
||||
// return 'https://…' // 播放地址字符串,或 { type, url }
|
||||
// }
|
||||
// 加载失败 / 单次取流抛错 / 超时都只算该候选失败,由 source.ts 候选链切换下一候选,
|
||||
// 不影响服务器其它请求。
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// 默认支持的平台,与 source.ts 的 SOURCES 一致(避免循环引用不互相 import)
|
||||
const DEFAULT_SOURCES = ['kw', 'kg', 'tx', 'wy']
|
||||
const DEFAULT_TIMEOUT = 15000
|
||||
|
||||
/**
|
||||
* 加载简单 API 音源脚本
|
||||
* @param {object} [options]
|
||||
* @param {string} options.scriptPath 脚本路径(必填,sources/ 扫描逻辑见 source.ts)
|
||||
* @returns { apis: Record<source, { getMusicUrl(songInfo, type) → { promise, cancelHttp } }> }
|
||||
*/
|
||||
export const createSimpleSource = async({ scriptPath }: Record<string, any> = {}) => {
|
||||
if (!scriptPath) throw new Error('缺少脚本路径 scriptPath')
|
||||
const mod: any = await import(pathToFileURL(scriptPath).href)
|
||||
if (typeof mod.getMusicUrl !== 'function') {
|
||||
throw new Error(`简单 API 脚本未导出 getMusicUrl 函数: ${scriptPath}`)
|
||||
}
|
||||
const sources = Array.isArray(mod.sources) && mod.sources.length ? mod.sources.map(String) : DEFAULT_SOURCES
|
||||
const timeoutMs = Number(mod.timeout) > 0 ? Number(mod.timeout) : DEFAULT_TIMEOUT
|
||||
console.log(`[source-simple] 已加载 ${scriptPath}(平台: ${sources.join(', ')})`)
|
||||
|
||||
const getMusicUrlFor = (source: string) => (songInfo: any, type: string) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
const controller = new AbortController()
|
||||
requestObj.cancelHttp = () => controller.abort(new Error('请求已取消'))
|
||||
const id = songInfo?.hash || songInfo?.songmid || songInfo?.copyrightId || ''
|
||||
const startedAt = Date.now()
|
||||
console.log(`[source-simple] ▶ ${source}/${type} id=${id}`)
|
||||
const work = Promise.resolve(mod.getMusicUrl({
|
||||
source,
|
||||
id,
|
||||
songInfo,
|
||||
quality: type,
|
||||
signal: controller.signal,
|
||||
}))
|
||||
// 超时后放弃等待,脚本仍可能在后台失败:提前挂上 catch 避免 unhandledRejection
|
||||
work.catch(() => {})
|
||||
requestObj.promise = (async() => {
|
||||
let timer: any
|
||||
try {
|
||||
const result: any = await Promise.race([
|
||||
work,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error('timeout'))
|
||||
reject(new Error(`脚本取流超时 (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
}),
|
||||
])
|
||||
const url = typeof result === 'string' ? result : result?.url
|
||||
if (!url) throw new Error('脚本未返回播放地址')
|
||||
console.log(`[source-simple] ✔ ${source}/${type} 完成(${Date.now() - startedAt}ms)`)
|
||||
return { type, url }
|
||||
} catch (err: any) {
|
||||
console.log(`[source-simple] ✖ ${source}/${type} 失败(${Date.now() - startedAt}ms)${err?.message || ''}`)
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
const apis: Record<string, any> = {}
|
||||
for (const s of sources) apis[s] = { getMusicUrl: getMusicUrlFor(s) }
|
||||
|
||||
// Record 索引签名:分平台接口同时平铺到顶层(与 source-ivm.ts 返回形状一致)
|
||||
const handle: Record<string, any> = {
|
||||
env: 'simple',
|
||||
sources,
|
||||
apis,
|
||||
destroy: () => Promise.resolve(),
|
||||
}
|
||||
return Object.assign(handle, apis)
|
||||
}
|
||||
|
||||
export default { createSimpleSource }
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// 外部聚合音源统一入口(服务端持有:SDK 只含官方平台接口,取流是否重定向到外部由 server 决策)
|
||||
// 两种脚本接入方式,放在 sources/ 目录按文件名混排成候选链(顺序即优先级):
|
||||
// 1. 社区音源脚本(.js):委托 source-ivm.ts 在硬隔离沙箱中执行(首次调用才加载初始化)。
|
||||
// 也可用环境变量 SOURCE_SCRIPT_PATH 指定目录外单脚本(此时跳过 .js 扫描,.mjs 仍参与)
|
||||
// 2. 简单 API 脚本(.mjs):source-simple.ts 进程内直接 import,无沙箱,
|
||||
// 只需导出 getMusicUrl —— 契约见 source-simple.ts 头注释
|
||||
// 多候选时启用失败切换与降权:取流失败自动切换下一个候选重试(实例懒创建,
|
||||
// 首次被用到才加载);某候选的平台连续失败达 FAIL_LIMIT 次进入冷却
|
||||
// ——选候选时沉到末尾,冷却期满自动恢复探测,成功一次即清零计数。
|
||||
// 统一接口约定:source.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createIvmSource } from './source-ivm.ts'
|
||||
import { createSimpleSource } from './source-simple.ts'
|
||||
|
||||
export const SOURCES = ['kw', 'kg', 'tx', 'wy']
|
||||
|
||||
const SOURCES_DIR = fileURLToPath(new URL('../sources/', import.meta.url))
|
||||
|
||||
// 连续失败阈值与降权冷却时长(毫秒)
|
||||
const FAIL_LIMIT = 3
|
||||
const COOLDOWN_MS = 5 * 60_000
|
||||
|
||||
interface SourceSlot {
|
||||
path: string
|
||||
name: string // 文件名,日志与健康度记录的标识
|
||||
kind: 'ivm' | 'simple' // .js → 社区脚本(沙箱);.mjs → 简单 API(进程内直载)
|
||||
instance: Promise<any> | null
|
||||
}
|
||||
|
||||
const resolveSlots = (): SourceSlot[] => {
|
||||
const make = (p: string, kind: SourceSlot['kind']): SourceSlot => ({ path: p, name: path.basename(p), kind, instance: null })
|
||||
const custom = process.env.SOURCE_SCRIPT_PATH
|
||||
const hasDir = fs.existsSync(SOURCES_DIR)
|
||||
if (!custom && !hasDir) {
|
||||
throw new Error(`音源目录不存在: ${SOURCES_DIR}(将脚本放入 sources/ 目录,或用 SOURCE_SCRIPT_PATH 指定)`)
|
||||
}
|
||||
const slots: SourceSlot[] = []
|
||||
if (custom) slots.push(make(custom, 'ivm'))
|
||||
// 指定 SOURCE_SCRIPT_PATH 时跳过 .js 扫描(保持单脚本语义),.mjs 简单脚本仍参与候选链
|
||||
if (hasDir) {
|
||||
for (const f of fs.readdirSync(SOURCES_DIR).sort()) {
|
||||
const lower = f.toLowerCase()
|
||||
if (lower.endsWith('.mjs') || (!custom && lower.endsWith('.js'))) {
|
||||
slots.push(make(path.join(SOURCES_DIR, f), lower.endsWith('.mjs') ? 'simple' : 'ivm'))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!slots.length) {
|
||||
throw new Error('sources/ 目录下没有音源脚本(.js = 社区脚本 / .mjs = 简单 API 脚本,或用 SOURCE_SCRIPT_PATH 指定)')
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// 懒解析候选脚本(保持"首次取流才碰磁盘"的原语义,目录缺失不阻塞启动)
|
||||
let slots: SourceSlot[] | null = null
|
||||
const getSlots = (): SourceSlot[] => {
|
||||
if (!slots) {
|
||||
slots = resolveSlots()
|
||||
if (slots.length > 1) {
|
||||
console.log(`[source] ${slots.length} 个音源脚本进入候选链(顺序即优先级,失败自动切换): ${slots.map(s => s.name).join(' → ')}`)
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// 健康度按「脚本 × 平台」计:某平台取流失败只降权该组合,不影响同一脚本的其他平台。
|
||||
// 仅多脚本时启用——单脚本无处可切,行为与原来一致。
|
||||
const failCounts = new Map<string, number>()
|
||||
const cooldownUntil = new Map<string, number>()
|
||||
|
||||
const healthKey = (slot: SourceSlot, source: string) => `${slot.name}:${source}`
|
||||
|
||||
const markFail = (slot: SourceSlot, source: string) => {
|
||||
if (getSlots().length < 2) return
|
||||
const key = healthKey(slot, source)
|
||||
const count = (failCounts.get(key) ?? 0) + 1
|
||||
failCounts.set(key, count)
|
||||
if (count >= FAIL_LIMIT) {
|
||||
cooldownUntil.set(key, Date.now() + COOLDOWN_MS)
|
||||
console.log(`[source] ${slot.name} 的 ${source} 连续失败 ${count} 次,降权 ${COOLDOWN_MS / 1000}s(候选末尾)`)
|
||||
}
|
||||
}
|
||||
|
||||
const markOk = (slot: SourceSlot, source: string) => {
|
||||
if (getSlots().length < 2) return
|
||||
const key = healthKey(slot, source)
|
||||
failCounts.delete(key)
|
||||
if (cooldownUntil.delete(key)) console.log(`[source] ${slot.name} 的 ${source} 恢复正常`)
|
||||
}
|
||||
|
||||
const isDemoted = (slot: SourceSlot, source: string) =>
|
||||
(cooldownUntil.get(healthKey(slot, source)) ?? 0) > Date.now()
|
||||
|
||||
// 候选顺序:该平台处于降权冷却期的脚本沉到末尾,其余保持文件名序
|
||||
const rankedSlots = (source: string): SourceSlot[] => {
|
||||
const list = getSlots()
|
||||
return list.length < 2
|
||||
? list
|
||||
: [...list].sort((a, b) => Number(isDemoted(a, source)) - Number(isDemoted(b, source)))
|
||||
}
|
||||
|
||||
const getInstance = (slot: SourceSlot): Promise<any> => {
|
||||
if (!slot.instance) {
|
||||
const load = slot.kind === 'simple'
|
||||
? createSimpleSource({ scriptPath: slot.path })
|
||||
: createIvmSource({
|
||||
scriptPath: slot.path,
|
||||
log: process.env.SOURCE_LOG !== '0',
|
||||
allowAllHttp: process.env.SOURCE_ALLOW_ALL_HTTP === '1',
|
||||
})
|
||||
// 配置可经环境变量调整,与项目其它适配器保持一致风格
|
||||
slot.instance = load.catch(err => {
|
||||
slot.instance = null // 失败允许下次重试
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return slot.instance
|
||||
}
|
||||
|
||||
const apiFor = (source) => ({
|
||||
getMusicUrl(songInfo, type) {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
let inner: any = null
|
||||
requestObj.cancelHttp = () => inner?.cancelHttp()
|
||||
requestObj.promise = (async () => {
|
||||
const candidates = rankedSlots(source)
|
||||
let lastErr: any = null
|
||||
for (const slot of candidates) {
|
||||
try {
|
||||
const api = await getInstance(slot)
|
||||
if (!api[source]) throw new Error(`脚本不支持的平台: ${source}`)
|
||||
const req = api[source].getMusicUrl(songInfo, type)
|
||||
inner = req // cancelHttp 始终取消当前在飞的尝试
|
||||
const result = await req.promise
|
||||
markOk(slot, source)
|
||||
return result
|
||||
} catch (err) {
|
||||
markFail(slot, source)
|
||||
lastErr = err
|
||||
if (candidates.length > 1) {
|
||||
console.log(`[source] ${slot.name} 取流失败(${source}): ${(err as any)?.message},切换下一候选脚本`)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('没有可用的音源脚本')
|
||||
})()
|
||||
return requestObj
|
||||
},
|
||||
})
|
||||
|
||||
// 单平台接入(server.ts 使用默认导出 sourceApi;createApi 供测试直接构造候选链)
|
||||
export const createApi = apiFor
|
||||
|
||||
const sourceApi: Record<string, any> = {}
|
||||
for (const s of SOURCES) sourceApi[s] = apiFor(s)
|
||||
|
||||
// 预热:提前触发首个候选脚本的加载与初始化(可选调用)
|
||||
sourceApi.init = () => getInstance(getSlots()[0])
|
||||
|
||||
export default sourceApi
|
||||
@@ -0,0 +1,164 @@
|
||||
// 简单 API 音源脚本(.mjs)加载器测试 —— 离线运行,不依赖外部平台
|
||||
// 运行:node test/source-simple-test.ts(已并入 pnpm test)
|
||||
//
|
||||
// 覆盖:基本取流(字符串 / {type,url} 返回)、sources 导出限定平台、signal 取消、
|
||||
// 超时中断、脚本抛错传播、缺少 getMusicUrl 导出报错、候选链失败切换(ivm→simple)
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createSimpleSource } from '../src/source-simple.ts'
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const tmpDir = path.join(root, 'tmp')
|
||||
const sourcesDir = path.join(root, 'sources')
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
|
||||
const results: { name: string; ok: boolean; detail?: any }[] = []
|
||||
const test = async(name: string, fn: () => Promise<any>) => {
|
||||
try {
|
||||
const result = await fn()
|
||||
results.push({ name, ok: true, detail: result })
|
||||
console.log(`✅ ${name}`)
|
||||
} catch (err: any) {
|
||||
results.push({ name, ok: false, detail: err.message })
|
||||
console.log(`❌ ${name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const songInfo = { songmid: '228908', hash: '228908', copyrightId: '228908' }
|
||||
|
||||
// 生成临时脚本并返回路径;测试结束后清理
|
||||
const writeScript = (dir: string, name: string, code: string) => {
|
||||
const p = path.join(dir, name)
|
||||
fs.writeFileSync(p, code)
|
||||
return p
|
||||
}
|
||||
const cleanup = (p: string) => { try { fs.unlinkSync(p) } catch {} }
|
||||
|
||||
await test('基本取流(返回字符串)', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-basic.mjs', `
|
||||
export async function getMusicUrl({ source, id, quality }) {
|
||||
return 'https://example.test/' + source + '/' + id + '/' + quality + '.mp3'
|
||||
}
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
const r = await api.kw.getMusicUrl(songInfo, '320k').promise
|
||||
assert.equal(r.type, '320k')
|
||||
assert.equal(r.url, 'https://example.test/kw/228908/320k.mp3')
|
||||
return r.url
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('返回 { type, url } 对象', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-obj.mjs', `
|
||||
export async function getMusicUrl({ quality }) {
|
||||
return { type: quality, url: 'https://example.test/obj.mp3' }
|
||||
}
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
const r = await api.tx.getMusicUrl(songInfo, 'flac').promise
|
||||
assert.deepEqual(r, { type: 'flac', url: 'https://example.test/obj.mp3' })
|
||||
return r.url
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('sources 导出限定平台', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-sources.mjs', `
|
||||
export const sources = ['mg']
|
||||
export async function getMusicUrl() { return 'https://example.test/x.mp3' }
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
assert.ok(api.mg, 'mg 应存在')
|
||||
assert.ok(!api.kw, 'kw 不应存在')
|
||||
const r = await api.mg.getMusicUrl({}, '128k').promise
|
||||
assert.equal(r.url, 'https://example.test/x.mp3')
|
||||
return '仅 mg'
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('cancelHttp 触发 signal', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-cancel.mjs', `
|
||||
export async function getMusicUrl({ signal }) {
|
||||
await new Promise((ok, err) => {
|
||||
const t = setTimeout(() => ok('late'), 5000)
|
||||
signal.addEventListener('abort', () => { clearTimeout(t); err(new Error('已取消')) })
|
||||
})
|
||||
}
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
const req = api.kw.getMusicUrl(songInfo, '128k')
|
||||
setTimeout(() => req.cancelHttp(), 50)
|
||||
await assert.rejects(req.promise, /已取消/)
|
||||
return 'abort 生效'
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('脚本超时中断', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-timeout.mjs', `
|
||||
export const timeout = 50
|
||||
export async function getMusicUrl() {
|
||||
await new Promise(ok => setTimeout(ok, 300))
|
||||
return 'https://example.test/never.mp3'
|
||||
}
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
await assert.rejects(api.kw.getMusicUrl(songInfo, '128k').promise, /超时/)
|
||||
return 'timeout 生效'
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('脚本抛错传播', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-error.mjs', `
|
||||
export async function getMusicUrl() { throw new Error('上游 403') }
|
||||
`)
|
||||
try {
|
||||
const api = await createSimpleSource({ scriptPath: p })
|
||||
await assert.rejects(api.kw.getMusicUrl(songInfo, '128k').promise, /上游 403/)
|
||||
return '错误传播正常'
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('缺少 getMusicUrl 导出报错', async() => {
|
||||
const p = writeScript(tmpDir, 'simple-test-bad.mjs', 'export const foo = 1')
|
||||
try {
|
||||
await assert.rejects(createSimpleSource({ scriptPath: p }), /getMusicUrl/)
|
||||
return '校验生效'
|
||||
} finally { cleanup(p) }
|
||||
})
|
||||
|
||||
await test('候选链:ivm 脚本失败自动切换到 .mjs', async() => {
|
||||
// SOURCE_SCRIPT_PATH 指向不存在的 .js 脚本(加载失败),sources/ 下的临时 .mjs 接管
|
||||
cleanup(path.join(sourcesDir, 'zz-test-simple.mjs')) // 清理上次异常退出的残留
|
||||
const p = writeScript(sourcesDir, 'zz-test-simple.mjs', `
|
||||
export async function getMusicUrl({ source }) {
|
||||
return 'https://example.test/chain-' + source + '.mp3'
|
||||
}
|
||||
`)
|
||||
process.env.SOURCE_SCRIPT_PATH = path.join(root, 'nonexistent-source-script.js')
|
||||
try {
|
||||
const { createApi } = await import('../src/source.ts')
|
||||
const r = await createApi('kw').getMusicUrl(songInfo, '128k').promise
|
||||
assert.equal(r.url, 'https://example.test/chain-kw.mp3')
|
||||
return '切换成功'
|
||||
} finally {
|
||||
cleanup(p)
|
||||
delete process.env.SOURCE_SCRIPT_PATH
|
||||
}
|
||||
})
|
||||
|
||||
// 汇总
|
||||
const passed = results.filter(r => r.ok).length
|
||||
const failed = results.filter(r => !r.ok)
|
||||
console.log(`\n===== 结果: ${passed}/${results.length} 通过 =====`)
|
||||
if (failed.length) {
|
||||
console.log('失败项:')
|
||||
failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`))
|
||||
}
|
||||
if (failed.length) process.exit(1)
|
||||
Reference in new issue
Block a user