From 96f6a7fe91685ccb07433e54bf0c0cbd122a7e16 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:35:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(source):=20=E5=A4=96=E9=83=A8=E9=9F=B3?= =?UTF-8?q?=E6=BA=90=E8=BF=81=20src/=20=E5=B9=B6=E6=96=B0=E5=A2=9E=20sourc?= =?UTF-8?q?e-simple=20=E8=BF=9B=E7=A8=8B=E5=86=85=E7=9B=B4=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 1 - docs/source-ivm.md | 4 +- docs/source-simple.md | 102 ++++++++++++++++++++ package.json | 2 +- sdk/apis/source.ts | 76 --------------- src/env.ts | 16 ++++ src/refresh.ts | 3 +- src/routes.ts | 14 ++- src/server.ts | 18 +++- {sdk/apis => src}/source-ivm.ts | 4 +- src/source-simple.ts | 97 +++++++++++++++++++ src/source.ts | 163 +++++++++++++++++++++++++++++++ test/source-simple-test.ts | 164 ++++++++++++++++++++++++++++++++ 13 files changed, 570 insertions(+), 94 deletions(-) create mode 100644 docs/source-simple.md delete mode 100644 sdk/apis/source.ts create mode 100644 src/env.ts rename {sdk/apis => src}/source-ivm.ts (99%) create mode 100644 src/source-simple.ts create mode 100644 src/source.ts create mode 100644 test/source-simple-test.ts diff --git a/.gitignore b/.gitignore index 3264739..6f068f0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ yarn.lock # 音源脚本(第三方、不可审计,不入库;放入 sources/ 自动加载) sources/ -lx-music-source-v5.js # Runtime *.log diff --git a/docs/source-ivm.md b/docs/source-ivm.md index 3850101..1fbad81 100644 --- a/docs/source-ivm.md +++ b/docs/source-ivm.md @@ -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 硬隔离沙箱 ┌──────────────────────────┐ │ 音源脚本(升版即换文件) │ diff --git a/docs/source-simple.md b/docs/source-simple.md new file mode 100644 index 0000000..03e720f --- /dev/null +++ b/docs/source-simple.md @@ -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 的死循环请求)由超时兜底放弃等待,脚本侧仍可能继续在后台运行 diff --git a/package.json b/package.json index 2d65141..f30e3f0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/sdk/apis/source.ts b/sdk/apis/source.ts deleted file mode 100644 index 31b2334..0000000 --- a/sdk/apis/source.ts +++ /dev/null @@ -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 | 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 = {} -for (const s of SOURCES) sourceApi[s] = apiFor(s) - -// 预热:提前触发脚本加载与初始化(可选调用) -sourceApi.init = getShared - -export default sourceApi diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..5a35933 --- /dev/null +++ b/src/env.ts @@ -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}`) + } +} diff --git a/src/refresh.ts b/src/refresh.ts index 5daf790..4731829 100644 --- a/src/refresh.ts +++ b/src/refresh.ts @@ -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' diff --git a/src/routes.ts b/src/routes.ts index 59885ef..8edd88a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -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, diff --git a/src/server.ts b/src/server.ts index afc7bb4..3ad729e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 // 响应信封:成功 { code: 0, data };失败 { code: , 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`) diff --git a/sdk/apis/source-ivm.ts b/src/source-ivm.ts similarity index 99% rename from sdk/apis/source-ivm.ts rename to src/source-ivm.ts index 5df9fc7..cae3ff3 100644 --- a/sdk/apis/source-ivm.ts +++ b/src/source-ivm.ts @@ -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":),并处理 \/ 转义 diff --git a/src/source-simple.ts b/src/source-simple.ts new file mode 100644 index 0000000..2a04ffe --- /dev/null +++ b/src/source-simple.ts @@ -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 } + */ +export const createSimpleSource = async({ scriptPath }: Record = {}) => { + 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 = {} + for (const s of sources) apis[s] = { getMusicUrl: getMusicUrlFor(s) } + + // Record 索引签名:分平台接口同时平铺到顶层(与 source-ivm.ts 返回形状一致) + const handle: Record = { + env: 'simple', + sources, + apis, + destroy: () => Promise.resolve(), + } + return Object.assign(handle, apis) +} + +export default { createSimpleSource } diff --git a/src/source.ts b/src/source.ts new file mode 100644 index 0000000..600e569 --- /dev/null +++ b/src/source.ts @@ -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 | 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() +const cooldownUntil = new Map() + +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 => { + 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 = {} +for (const s of SOURCES) sourceApi[s] = apiFor(s) + +// 预热:提前触发首个候选脚本的加载与初始化(可选调用) +sourceApi.init = () => getInstance(getSlots()[0]) + +export default sourceApi diff --git a/test/source-simple-test.ts b/test/source-simple-test.ts new file mode 100644 index 0000000..60eda7a --- /dev/null +++ b/test/source-simple-test.ts @@ -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) => { + 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)