refactor!: 移除品牌字样,音源改为 sources/ 目录自动加载,脚本不入库

This commit is contained in:
snowy-yang committed 2026-09-16 21:28:50 +08:00
1 parent f548e5990b
commit 5dd0468860
34 files changed
+256 -232

No files matched your search

+4
View File
@@ -3,6 +3,10 @@ node_modules/
package-lock.json
yarn.lock
# 音源脚本(第三方、不可审计,不入库;放入 sources/ 自动加载)
sources/
lx-music-source-v5.js
# Runtime
*.log
logs/
+5 -5
View File
@@ -1,6 +1,6 @@
# 音乐服务器(MusicServer)
基于洛雪音乐(lx-music)聚合 SDK 的多平台音乐 API 服务器,纯 Node.js 实现,无需浏览器环境。
多平台音乐聚合 API 服务器,纯 Node.js 实现,无需浏览器环境。
## 功能
@@ -25,7 +25,7 @@ pnpm test # 运行冒烟测试(自动启动/关闭本地服务器)
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/lxlyric) |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
| `GET /pic/:source/:id` | 获取封面 |
| `GET /boards/:source` | 榜单列表 |
| `GET /boards/:source/:boardId` | 榜单详情 |
@@ -59,8 +59,8 @@ src/
components.js OpenAPI 共享 schema(与路由校验同源)
ui.js ReDoc 页面模板
apis/ 各平台 URL 直连实现(kw/kg/tx/wy/mg/bd)
lx-source.js lx 聚合源懒加载入口
lx-source-ivm.js isolated-vm 隔离执行器
source.js 外部音源懒加载入口(sources/ 目录脚本,不入库)
source-ivm.js isolated-vm 隔离执行器
common/ 公共模块
request.js HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
utils.js 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
@@ -70,7 +70,7 @@ src/
lyricDecode.js 酷我/QQ 歌词解码(替代原 Electron 主进程处理器)
qrcDecode.js QQ 歌词 3DES 解密(纯 JS 移植)
lyricUtils/kg.js 酷狗 krc 歌词解码
musicSdk/ 洛雪音乐聚合 SDK(搜索/歌词/榜单等核心逻辑,TypeScript 源码)
musicSdk/ 音乐聚合 SDK(搜索/歌词/榜单等核心逻辑,TypeScript 源码)
index.js 平台聚合入口
request.js SDK 请求模块(转接 src/common/request.js)
message.js SDK 消息模块(转接 src/common/message.js)
+1 -1
View File
@@ -71,7 +71,7 @@
{ "code": 0, "data": { "type": "320k", "url": "https://..." } }
// GET /lyric/kw/228908
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "lxlyric": "" } }
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } }
// 参数缺失
// 422
-48
View File
@@ -1,48 +0,0 @@
# lx 聚合源 —— isolated-vm 加载器
## 架构
```
调用方 ── lx.kw.getMusicUrl(songInfo, type) ──▶ src/apis/lx-source.js(懒加载单例)
│
▼
src/apis/lx-source-ivm.js
isolated-vm 硬隔离沙箱
┌──────────────────────────┐
│ 原始混淆脚本(升版即换文件) │
│ ↕ 仅经显式桥接通道 │
└──────────────────────────┘
http(白名单) crypto zlib 定时器 console
```
- 接口契约与其它平台适配器一致:`getMusicUrl(songInfo, type) → { promise, cancelHttp }`,resolve → `{ type, url }`
- 脚本升版:替换项目根目录脚本文件(或设 `LX_SOURCE_SCRIPT_PATH`)即可,无需改代码
## 安全模型
| 面 | 措施 |
|---|---|
| 进程 | isolate 无 process/require/fs,内存上限 256MB(可调) |
| HTTP | **默认阻止**;白名单取脚本头部 `SERVER_SCRIPT_CONFIG.apiUrl` 同源,可覆盖 |
| 密钥 | crypto 桥接日志只记操作与长度,不落密钥内容 |
| 观测 | 每个 lx API 调用(on/send/request/utils/dispatch)均有日志,请求含耗时与状态码 |
HTTP 白名单选项(`createIvmLxSource`):
- `allowedOrigins: string[]` —— 显式指定白名单(覆盖脚本声明)
- `httpFilter: (url) => boolean` —— 自定义判定,优先于 origin 白名单
- `allowAllHttp: true` —— 全部放行(仅调试用)
被拦截的请求会以错误回调返回脚本(`✋` 日志标记),脚本按请求失败自行处理。
## 环境变量(经 lx-source.js 封装)
- `LX_SOURCE_SCRIPT_PATH` —— 脚本文件路径(默认项目根目录 `lx-music-source-v5.js`)
- `LX_SOURCE_LOG=0` —— 关闭日志
- `LX_SOURCE_ALLOW_ALL_HTTP=1` —— 放行全部 HTTP(调试用)
## 已知边界
- `lx.request` 回调的 resp 不含 `raw`/`bytes`(Buffer 无法跨隔离边界,实测脚本未使用)
- `formData` 选项不支持(底层 httpFetch 不支持 multipart)
- `updateAlert` 事件仅记日志,不弹窗
+57
View File
@@ -0,0 +1,57 @@
# 外部聚合音源 —— isolated-vm 加载器
## 架构
```
调用方 ── source.kw.getMusicUrl(songInfo, type) ──▶ src/apis/source.js(懒加载单例)
│
▼
src/apis/source-ivm.js
isolated-vm 硬隔离沙箱
┌──────────────────────────┐
│ 音源脚本(升版即换文件) │
│ ↕ 仅经显式桥接通道 │
└──────────────────────────┘
http(白名单) crypto zlib 定时器 console
```
- 接口契约与其它平台适配器一致:`getMusicUrl(songInfo, type) → { promise, cancelHttp }`,resolve → `{ type, url }`
- 脚本来源:默认自动读取 `sources/` 目录下按文件名排序的第一个 `.js`;也可用 `SOURCE_SCRIPT_PATH` 显式指定
- 脚本升版:替换 `sources/` 目录下的脚本文件即可,无需改代码
- 脚本文件含第三方混淆内容,不入库(`.gitignore` 已排除 `sources/`)
## 安全模型
| 面 | 措施 |
|---|---|
| 进程 | isolate 无 process/require/fs,内存上限 256MB(可调) |
| HTTP | **默认阻止**;白名单取脚本头部 `SERVER_SCRIPT_CONFIG.apiUrl` 同源,可覆盖 |
| 密钥 | crypto 桥接日志只记操作与长度,不落密钥内容 |
| 观测 | 每个音源 API 调用(on/send/request/utils/dispatch)均有日志,请求含耗时与状态码 |
HTTP 白名单选项(`createIvmSource`):
- `allowedOrigins: string[]` —— 显式指定白名单(覆盖脚本声明)
- `httpFilter: (url) => boolean` —— 自定义判定,优先于 origin 白名单
- `allowAllHttp: true` —— 全部放行(仅调试用)
被拦截的请求会以错误回调返回脚本(`✋` 日志标记),脚本按请求失败自行处理。
## 启用方式
设置环境变量 `SOURCE_ENABLED`(任意非 `0`/`false` 值)后启动服务器,取流请求即走音源;
不设置或为空则使用内置直连接口。歌词、封面、榜单等其余能力始终走内置实现。
## 环境变量(经 source.js 封装)
- `SOURCE_ENABLED=1` —— 启用音源(见上)
- `SOURCE_SCRIPT_PATH` —— 脚本文件路径(缺省扫描 `sources/` 目录)
- `SOURCE_LOG=0` —— 关闭日志
- `SOURCE_ALLOW_ALL_HTTP=1` —— 放行全部 HTTP(调试用)
## 已知边界
- 音源请求回调的 resp 不含 `raw`/`bytes`(Buffer 无法跨隔离边界,实测脚本未使用)
- `formData` 选项不支持(底层 httpFetch 不支持 multipart)
- `updateAlert` 事件仅记日志,不弹窗
- 沙箱内全局对象名 `lx` 为第三方脚本依赖的 API 契约名,不可改名(见 `source-ivm.js` GLUE 注释)
-13
View File
@@ -1,13 +0,0 @@
/*!
* @name 独家音源
* @description 后续更新,请关注微信公众号: 洛雪科技
* @version 5
* @author w
*/
// ===== 服务端下发配置(自动生成, 请勿修改) =====
globalThis['SERVER_SCRIPT_CONFIG'] = {"apiUrl":"https:\/\/88.lxmusic.xn--fiqs8s","apiKey":"lxmusic","signSalt":"LxSrv@2026#Sig","fingerprint":"74c52ba24b386a2301983d94596246c2"};
// ===== 服务端下发配置结束 =====
;(function ﱡﹶ(ﱡﹲ,ﱡﱣ,ﹲ‌,ﱞﹶ,ﹲﹰ,ﹸﹼ,ﹰﱣ,ﹰﹼ,ﹴﱠ,ﹸﹸ,ءﱟ,ﱟﹸ,ﹰﹲ,ﱠﹴ,ﱞﹲ,ﹼﱣ,ﹶﹲ,ﹶﹰ){return ﹶﹰ=function(ﱟﹺ,ﹼ,ﹰﹺ,ءﱟ){return function(ﹼﹶ,ﱡء,ءﱡ,ﱡﹲ,ﹲ‌,ﹲﹰ,ﹸﹼ,ﹰﱣ,ﹴﱠ){if(ﹼﹶ=ﹼﹶ^ﱡء,ﹲﹰ=66666-ﹼﹶ,ﹸﹼ=ﹶﹲ[ﹲﹰ],ﹸﹼ!==void 0)return ﹸﹼ;for(ﹲ‌=ﱡﱣ[ﹼﹶ],ﱡﹲ=[],ﹸﹼ=ﹰﹺ(ﹼﹶ,ﱡء,ﹲ‌.\u006c\u0065\u006e\u0067\u0074\u0068,ﹼ,ﱟﹺ),ﹰﱣ=0,ﹼﹶ=(ﹸﹼ^ﹸﹼ>>>16^ﱡء^ﹼﹶ)&65535,ءﱡ=0;ءﱡ<ﹲ‌.\u006c\u0065\u006e\u0067\u0074\u0068;ءﱡ++){(ءﱡ&1)===0&&(ﹸﹼ=ﹸﹼ+2654435769>>>0,ﹰﱣ=ﹼ(ﹸﹼ,ﱟﹺ));ﱡء=ﹰﱣ>>>((ءﱡ&1)<<4)&65535,ﹴﱠ=ﹲ‌[ءﱡ],ﹰﹼ(ﱡﹲ,(ﹴﱠ^ﱡء^ﹼﹶ)&65535),ﹼﹶ=ﹴﱠ+(ﹰﱣ>>>16&65535)+ءﱡ+40503&65535}return ﹶﹲ[ﹲﹰ]=ﹸﹸ(ﱡﹲ)}}(function(ﹼﹶ,ﱟﹺ,ﱡء,ﹼ){return ﱡء=ﹼﹶ>>>16&65535,ﹼﹶ=ﹼﹶ&65535,ﹼ=ﱟﹺ>>>16&65535,ﱟﹺ=ﱟﹺ&65535,ﹼﹶ*ﱟﹺ+(ﱡء*ﱟﹺ+ﹼﹶ*ﹼ<<16>>>0)|0},function(ﹼﹶ,ﱟﹺ){return ﹼﹶ>>>=0,ﹼﹶ=ﱟﹺ(ﹼﹶ^ﹼﹶ>>>16,569420461),ﹼﹶ=ﱟﹺ(ﹼﹶ^ﹼﹶ>>>15,1935289751),(ﹼﹶ^ﹼﹶ>>>15)>>>0},function(ﹸ‍,ﱡء,ءﱡ,ﹼ,ﱟﹺ){return ﹼ(1327381188>>>0^ﱟﹺ(ﹸ‍+1>>>0,2654435761)^ﱟﹺ(ﱡء+1>>>0,2246822507)^ﱟﹺ(ءﱡ+1>>>0,3266489909),ﱟﹺ)}),function ﹰﹺ(ﹼﹶ,ﱟﹺ,ﱡء,ﹼ,ءﱡ){ﱟﹺ=ﱟﹺ||[],ﱡء=ﱡء||[],ﹼ=ﹼ||[];for(var ﱡﹴ=ﱡء.\u006c\u0065\u006e\u0067\u0074\u0068,ءﹶ,ﹰﹸ;ﹰﹸ=ﱡﹲ[ﹼﹶ++];)if(61<ﹰﹸ)ﹰﹸ<68&&((ﹰﹸ>65||(ﹰﹸ<64&&((ﹰﹸ-->62||(ﱡء[++ﱡﹴ-ﹰﹸ+++60]=function(ﱡء,ﱡﹴ){return function(){return ﹶﹰ(ﱡء,ﱡﹴ)}}(ﱡﹲ[ﹼﹶ++]^-++ﹰﹸ+32881+ﹼﹶ,ﱡﹲ[ﹼﹶ++]^-ﹰﹸ--+49974+ﹼﹶ),!true))&&(ﱡء[ﱡﹴ+++ﹰﹸ---62]=function(ﱡء){return function(){return ﱟﹺ[ﱡء]++}}(ﱡﹲ[ﹼﹶ++]^--ﹰﹸ+57119+ﹼﹶ)),!0)||ﹰﹸ>64&&(ﱡء[ﱡﹴ+++ﹰﹸ-67]()||(ﹼﹶ=ﱡء[ﱡﹴ-ﹰﹸ+++63]()),(ﱡﹴ-=3)+ﹰﹸ+++31,1)||(ﱡء[ﱡﹴ+++ﹰﹸ-64]=function(){return function(){return typeof \u0070\u0072\u006f\u0063\u0065\u0073\u0073}}()),""))&&(66<ﹰﹸ||(ﱡء[(ﱡﹴ+=101)- --ﹰﹸ-38]=function(ﱡء,ﱡﹴ){return function(){return new ﹰﹲ(ﱡء(),ﱡﹴ())}}(ﱡء[++ﱡﹴ-104],ﱡء[(ﱡﹴ-=103)+ﹰﹸ++-65]),void 0))&&(ﱡء[--ﱡﹴ+ﹰﹸ-67]=function(ﱡء){return function(){return!ﱡء()}}(ﱡء[++ﱡﹴ- --ﹰﹸ+65])),!false)||73>ﹰﹸ&&(ﹰﹸ<70&&(ﹰﹸ<69&&(ﱡء[++ﱡﹴ+ ++ﹰﹸ-70]=function(){return function(){return \u0070\u0061\u0072\u0073\u0065\u0049\u006e\u0074}}(),!0)||(ﱡء[ﱡﹴ+++ ++ﹰﹸ-70]=function(){return function(){return \u0050\u0072\u006f\u006d\u0069\u0073\u0065}}()),true)||(71<ﹰﹸ||((71<++ﹰﹸ||(ﱡء[(ﱡﹴ+=--ﹰﹸ)-78]=function(ﱡء,ﱡﹴ,ﹰﹸ,ﹼﹶ,ﹰﱞ,ﹰء,ﹲﹼ,ﹼﱡ){return function(){return ﹰﱣ(ﱡء())(ﱡﹴ(),ﹰﹸ(),ﹼﹶ(),ﹰﱞ(),ﹰء(),ﹲﹼ(),ﹼﱡ())()}}(ﱡء[(ﱡﹴ+=43)-++ﹰﹸ-49],ﱡء[--ﱡﹴ+ﹰﹸ++-191],ﱡء[ﱡﹴ++-118],ﱡء[(ﱡﹴ+=8)+ﹰﹸ---198],ﱡء[--ﱡﹴ-ﹰﹸ-53],ﱡء[(ﱡﹴ+=ﹰﹸ--)-194],ﱡء[ﱡﹴ+ﹰﹸ++-263],ﱡء[(ﱡﹴ-=198)-ﹰﹸ+77]),![]))&&(ﱡء[(ﱡﹴ+=3)-ﹰﹸ--+67]()&&(ﹼﹶ=ﱡء[ﱡﹴ-ﹰﹸ--+67]()),(ﱡﹴ-=5)-ﹰﹸ+++115),null))&&(ﱡء[ﱡﹴ+++ ++ﹰﹸ-73]=function(){return function(){return \u0063\u006f\u006e\u0073\u006f\u006c\u0065}}()),!false)||ﹰﹸ>77&&(ﹰﹸ>81&&(83>ﹰﹸ--&&(ﱡء[ﱡﹴ+++ﹰﹸ---81]=function(ﱡء){return function(){return ﱡء}}(ﹼﱣ),!null)||(ﱡء[(ﱡﹴ-=10)-ﹰﹸ+89]=function(ﱡء,ﱡﹴ,ﹰﹸ,ﹼﹶ){return function(){return ﹸﹼ(ﱡء(),(ﱡﹴ()[0]=ﹰﹸ(),ﹼﹶ()))()}}(ﱡء[ﱡﹴ+ﹰﹸ-74],ﱡء[(ﱡﹴ+=36)+ﹰﹸ-109],ﱡء[(ﱡﹴ+=ﹰﹸ)-111],ﱡء[(ﱡﹴ-=110)+ﹰﹸ---81])),!null)||++ﹰﹸ>81&&(ﱡء[ﱡﹴ-ﹰﹸ+80]=function(ﱡء,ﱡﹴ){return function(){return delete ﱡء()[ﱡﹴ()]}}(ﱡء[(ﱡﹴ+=ﹰﹸ++)-84],ﱡء[(ﱡﹴ-=83)+ﹰﹸ---83]),!null)||ﹰﹸ>80&&(ﱡء[ﱡﹴ+++ ++ﹰﹸ-82]=function(){return function(){return \u0053\u0079\u006d\u0062\u006f\u006c}}(),1)||80>ﹰﹸ&&(ﱡء[ﱡﹴ++]=function(){return function(){return \u004f\u0062\u006a\u0065\u0063\u0074}}(),!0)||(ﱡء[ﱡﹴ++]=function(){return function(){return \u0072\u0065\u0071\u0075\u0069\u0072\u0065}}()),!false)||(ﹰﹸ>76||((ﹰﹸ>75||(ﹰﹸ<75&&((ﹰﹸ<74||(ﱡء[(ﱡﹴ+=92)+ ++ﹰﹸ-168]=function(ﱡء){return function(){return-ﱡء()}}(ﱡء[(ﱡﹴ-=92)-++ﹰﹸ+75]),void 0))&&(ﱡء[ﱡﹴ--+--ﹰﹸ-74]=function(ﱡء,ﱡﹴ){return function(){return ﱡء()+ﱡﹴ()}}(ﱡء[(ﱡﹴ-=--ﹰﹸ)+70],ﱡء[(ﱡﹴ+=71)-ﹰﹸ--+71])),1)||(ﱡء[(ﱡﹴ+=ﹰﹸ)-77]=function(ﱡء,ﱡﹴ){return function(){return ﱡء()*ﱡﹴ()}}(ﱡء[ﱡﹴ++-ﹰﹸ++-2],ﱡء[(ﱡﹴ-=77)+--ﹰﹸ-75])),void 0))&&(ﱡء[++ﱡﹴ-ﹰﹸ--+73]=function(ﱡء,ﱡﹴ){return function(){return ﱡء()<=ﱡﹴ()}}(ﱡLine truncated
+2 -2
View File
@@ -23,8 +23,8 @@ const sources: ApiSourceInfo[] = [
// },
// },
{
id: 'v5',
name: 'lx-music-source-v5 聚合源',
id: 'source',
name: '外部聚合音源',
disabled: false,
supportQualitys: {
kw: ['128k', '320k', 'flac'],
+7 -7
View File
@@ -1,8 +1,8 @@
// 接口选择层:按 apiSource 配置把请求路由到 v5 聚合源或服务器内置直连接口
// 接口选择层:按 apiSource 配置把请求路由到外部聚合音源或服务器内置直连接口
import apiSourceInfo from './api-source-info.ts'
import { apiSource, userApi } from '../src/renderer/store.js'
import internalApi from '../src/apis/index.js'
import { createApi as createLxSourceApi } from '../src/apis/lx-source.js'
import { createApi as createSourceApi } from '../src/apis/source.js'
interface SourceApi {
getMusicUrl(songInfo: any, type: string): { promise: Promise<any>; cancelHttp(): void }
@@ -10,11 +10,11 @@ interface SourceApi {
}
const allApi: Record<string, SourceApi> = {
v5_kw: createLxSourceApi('kw'),
v5_kg: createLxSourceApi('kg'),
v5_tx: createLxSourceApi('tx'),
v5_wy: createLxSourceApi('wy'),
v5_mg: createLxSourceApi('mg'),
source_kw: createSourceApi('kw'),
source_kg: createSourceApi('kg'),
source_tx: createSourceApi('tx'),
source_wy: createSourceApi('wy'),
source_mg: createSourceApi('mg'),
}
const apiList: Record<string, SourceApi | undefined> = {}
+1 -1
View File
@@ -82,7 +82,7 @@ export default {
lyric: Buffer.from(body.content, 'base64').toString('utf-8'),
tlyric: '',
rlyric: '',
lxlyric: '',
yrc: '',
}
default:
return Promise.reject(new Error(`未知歌词格式: ${body.fmt}`))
+4 -4
View File
@@ -224,10 +224,10 @@ export default {
// return Promise.reject(new Error('Get lyric failed'))
// }
// if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
// lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric)
// lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
// // console.log(lrcInfo.lyric)
// // console.log(lrcInfo.tlyric)
// // console.log(lrcInfo.lxlyric)
// // console.log(lrcInfo.yrc)
// // console.log(JSON.stringify(lrcInfo))
// })
// })
@@ -255,9 +255,9 @@ export default {
// console.log(lrcInfo)
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
try {
lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric)
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
} catch {
lrcInfo.lxlyric = ''
lrcInfo.yrc = ''
}
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
+4 -4
View File
@@ -11,7 +11,7 @@ const mrcTools = {
parseLyric(str) {
str = str.replace(/\r/g, '')
const lines = str.split('\n')
const lxlrcLines: string[] = []
const yrcLines: string[] = []
const lrcLines: string[] = []
for (const line of lines) {
@@ -40,11 +40,11 @@ const mrcTools = {
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`[${time}]${newWords}`)
yrcLines.push(`[${time}]${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n'),
yrc: yrcLines.join('\n'),
}
},
getText(url, tryNum = 0) {
@@ -67,7 +67,7 @@ const mrcTools = {
})
},
getLrc(url) {
return this.getText(url).then(text => ({ lxlyric: '', lyric: text }))
return this.getText(url).then(text => ({ yrc: '', lyric: text }))
},
getTrc(url) {
if (!url) return Promise.resolve('')
+1 -1
View File
@@ -72,7 +72,7 @@ const toBigintArray = (data: string): bigint[] => {
return jArr
}
// https://github.com/lyswhut/lx-music-desktop/issues/445#issuecomment-1139338682
// 基于社区通用的 mrc 逐字歌词解析思路
const MAX = 9223372036854775807n
const MIN = -9223372036854775808n
const toLong = (str: string | bigint): bigint => {
+1 -1
View File
@@ -2,7 +2,7 @@
export const bHh = '624868746c'
export const headers: Record<string, string | string[]> = {
'User-Agent': 'lx-music request',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
[bHh]: [bHh],
}
+11 -11
View File
@@ -30,10 +30,10 @@ const parseTools = {
parseLyric(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
if (!lrc) return { lyric: '', yrc: '' }
const lines = lrc.split('\n')
const lxlrcLines: string[] = []
const yrcLines: string[] = []
const lrcLines: string[] = []
for (let line of lines) {
@@ -41,11 +41,11 @@ const parseTools = {
let result = this.rxps.lineTime.exec(line)
if (!result) {
if (line.startsWith('[offset')) {
lxlrcLines.push(line)
yrcLines.push(line)
lrcLines.push(line)
}
if (this.rxps.lineTime2.test(line)) {
// lxlrcLines.push(line)
// yrcLines.push(line)
lrcLines.push(line)
}
continue
@@ -67,17 +67,17 @@ const parseTools = {
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`${startTimeStr}${newWords}`)
yrcLines.push(`${startTimeStr}${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n'),
yrc: yrcLines.join('\n'),
}
},
parseRlyric(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
if (!lrc) return { lyric: '', yrc: '' }
const lines = lrc.split('\n')
const lrcLines: string[] = []
@@ -177,14 +177,14 @@ const parseTools = {
lyric: '',
tlyric: '',
rlyric: '',
lxlyric: '',
yrc: '',
}
if (lrc) {
let { lyric, lxlyric } = this.parseLyric(this.removeTag(lrc))
let { lyric, yrc } = this.parseLyric(this.removeTag(lrc))
info.lyric = lyric
info.lxlyric = lxlyric
info.yrc = yrc
// console.log(lyric)
// console.log(lxlyric)
// console.log(yrc)
}
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
+1 -1
View File
@@ -82,7 +82,7 @@ export interface LyricInfo {
lyric: string
tlyric: string
rlyric?: string
lxlyric?: string
yrc?: string
}
/** 搜索结果 */
+11 -11
View File
@@ -8,7 +8,7 @@ import type { LyricInfo, HttpRequestObject } from '../types.ts'
// str = str.replace(/\r/g, '')
// let lxlyric = str.replace(/\[((\d+),\d+)\].*/g, str => {
// let yrc = str.replace(/\[((\d+),\d+)\].*/g, str => {
// let result = str.match(/\[((\d+),\d+)\].*/)
// let time = parseInt(result[2])
// let ms = time % 1000
@@ -31,8 +31,8 @@ import type { LyricInfo, HttpRequestObject } from '../types.ts'
// return str
// })
// lxlyric = decodeName(lxlyric)
// return lxlyric.trim()
// yrc = decodeName(yrc)
// return yrc.trim()
// }
const eapiRequest = (url: string, data: object) => {
@@ -72,7 +72,7 @@ const parseTools = {
return `[${m}:${s}.${ms}]`
},
parseLyric(lines) {
const lxlrcLines: string[] = []
const yrcLines: string[] = []
const lrcLines: string[] = []
for (let line of lines) {
@@ -80,7 +80,7 @@ const parseTools = {
let result = this.rxps.lineTime.exec(line)
if (!result) {
if (line.startsWith('[offset')) {
lxlrcLines.push(line)
yrcLines.push(line)
lrcLines.push(line)
}
continue
@@ -103,11 +103,11 @@ const parseTools = {
const wordArr = words.split(this.rxps.wordTime)
wordArr.shift()
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`${startTimeStr}${newWords}`)
yrcLines.push(`${startTimeStr}${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n'),
yrc: yrcLines.join('\n'),
}
},
parseHeaderInfo(str) {
@@ -170,7 +170,7 @@ const parseTools = {
lyric: '',
tlyric: '',
rlyric: '',
lxlyric: '',
yrc: '',
}
if (ylrc) {
let lines = this.parseHeaderInfo(ylrc)
@@ -196,7 +196,7 @@ const parseTools = {
const timeRxp = /^\[[\d:.]+\]/
const headers = lines.filter(l => timeRxp.test(l)).join('\n')
info.lyric = `${headers}\n${result.lyric}`
info.lxlyric = result.lxlyric
info.yrc = result.yrc
return info
}
}
@@ -242,13 +242,13 @@ const parseTools = {
// lyric: body.lrc.lyric,
// tlyric: body.tlyric?.lyric ?? '',
// rlyric: body.romalrc?.lyric ?? '',
// // lxlyric: parseLyric(body.klyric.lyric),
// // yrc: parseLyric(body.klyric.lyric),
// }
// })
// return requestObj
// }
// https://github.com/lyswhut/lx-music-mobile/issues/370
// 参考社区通用的歌词时间标签修正思路
const fixTimeLabel = (lrc: string, tlrc?: string, romalrc?: string) => {
if (lrc) {
let newLrc = lrc.replace(/\[(\d{2}:\d{2}):(\d{2})]/g, '[$1.$2]')
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "music-server",
"version": "1.0.0",
"description": "多平台音乐聚合服务器(基于 lx-music 聚合 SDK)",
"description": "多平台音乐聚合服务器",
"type": "module",
"main": "src/index.js",
"packageManager": "pnpm@12.4.1",
+1 -3
View File
@@ -1,13 +1,12 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// lx 为 lx-music 聚合源:isolated-vm 硬隔离执行原始脚本(签名方案已放弃,见 docs/lx-source-ivm.md)
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
import kw from './kw.js';
import kg from './kg.js';
import tx from './tx.js';
import wy from './wy.js';
import mg from './mg.js';
import bd from './bd.js';
import lx from './lx-source.js';
export default {
kw: kw,
kg: kg,
@@ -15,5 +14,4 @@ export default {
wy: wy,
mg: mg,
bd: bd,
lx: lx,
};
+1 -3
View File
@@ -1,6 +1,6 @@
// 各平台 URL 直连接口汇总
// 服务器环境固定使用内置直连接口(apiSource.value === 'internal')
// lx 为 lx-music 聚合源:isolated-vm 硬隔离执行原始脚本(签名方案已放弃,见 docs/lx-source-ivm.md)
// 外部聚合音源(sources/ 目录脚本)不走这里,由 musicSdk/api-source.ts 直接接入 source.js
import kw from './kw.js'
import kg from './kg.js'
@@ -8,7 +8,6 @@ import tx from './tx.js'
import wy from './wy.js'
import mg from './mg.js'
import bd from './bd.js'
import lx from './lx-source.js'
interface SongInfo {
songmid?: string
@@ -34,5 +33,4 @@ export default {
wy: wy as ApiModule,
mg: mg as ApiModule,
bd: bd as ApiModule,
lx: lx as unknown as Record<string, ApiModule>,
}
-52
View File
@@ -1,52 +0,0 @@
// lx 聚合源统一入口(isolated-vm 懒加载封装)
// 内部委托 lx-source-ivm.js 在硬隔离沙箱中执行原始脚本(签名方案已放弃:v5 的
// to/si 为脚本内部哈希、服务端独立校验,直连复刻不可行,详见 docs/lx-source-ivm.md)。
// 首次调用 getMusicUrl 时才加载并初始化隔离环境,后续复用同一实例。
// 统一接口约定:lx.kw.getMusicUrl(songInfo, type) → { promise, cancelHttp },resolve → { type, url }
import { createIvmLxSource } from './lx-source-ivm.js'
export const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']
let sharedPromise = null
const getShared = () => {
if (!sharedPromise) {
// 配置可经环境变量调整,与项目其它适配器保持一致风格
sharedPromise = createIvmLxSource({
scriptPath: process.env.LX_SOURCE_SCRIPT_PATH || undefined,
log: process.env.LX_SOURCE_LOG !== '0',
allowAllHttp: process.env.LX_SOURCE_ALLOW_ALL_HTTP === '1',
}).catch(err => {
sharedPromise = null // 失败允许下次重试
throw err
})
}
return sharedPromise
}
const apiFor = (source) => ({
getMusicUrl(songInfo, type) {
const requestObj = { cancelHttp() {}, promise: null }
let inner = null
requestObj.cancelHttp = () => inner?.cancelHttp()
requestObj.promise = (async () => {
const lx = await getShared()
if (!lx[source]) throw new Error(`脚本不支持的平台: ${source}`)
inner = lx[source].getMusicUrl(songInfo, type)
return inner.promise
})()
return requestObj
},
})
// 兼容导出:musicSdk/api-source.js 以 createApi(source) 方式接入 v5_* 聚合源
export const createApi = apiFor
const lxSource = {}
for (const s of SOURCES) lxSource[s] = apiFor(s)
// 预热:提前触发脚本加载与初始化(可选调用)
lxSource.init = getShared
export default lxSource
@@ -1,39 +1,36 @@
// lx 聚合源加载器(isolated-vm 硬隔离执行第三方脚本)
// 外部聚合音源加载器(isolated-vm 硬隔离执行第三方脚本)
//
// 这是 lx 聚合源的唯一接入方式:在 isolated-vm 硬隔离沙箱中运行原始混淆脚本
// (如 lx-music-source-v5.js),脚本的所有能力都经由本模块显式桥接的通道:
// 这是外部音源的唯一接入方式:在 isolated-vm 硬隔离沙箱中运行原始混淆脚本,
// 脚本的所有能力都经由本模块显式桥接的通道:
// - http:默认只放行脚本头部 SERVER_SCRIPT_CONFIG.apiUrl 同源请求,其余一律阻止
// - crypto / zlib / buffer:Node 侧实现,日志只记操作与长度,不记录密钥内容
// - 定时器 / console:桥接到 Node,随 isolate 销毁回收
// 对外接口:getMusicUrl(songInfo, type) → { promise, cancelHttp },与其它平台适配器一致。
// 脚本升版时只需替换脚本文件,无需改代码。
// 脚本升版时只需替换 sources/ 目录下的脚本文件,无需改代码。
//
// 安全模型:
// - isolate 内存上限(memoryLimit),无 process/require/fs/网络直连能力
// - HTTP 出站默认拒绝(deny-all),白名单来自脚本自身声明的 apiUrl,可被 options 收紧或放宽
// - 每个 lx API 调用(on/send/request/utils/dispatch)都有日志,请求含耗时与状态
// - 每个音源 API 调用(on/send/request/utils/dispatch)都有日志,请求含耗时与状态
//
// 依赖:isolated-vm(未安装时工厂函数抛出明确错误)
// 用法:
// import { createIvmLxSource } from './lx-source-ivm.js'
// const lx = await createIvmLxSource() // 默认加载项目根目录的 lx-music-source-v5.js
// const lx = await createIvmLxSource({ scriptPath, http }) // http 可注入 mock 做离线测试
// await lx.kw.getMusicUrl({ songmid: '123' }, '320k').promise
// await lx.destroy()
// import { createIvmSource } from './source-ivm.js'
// const source = await createIvmSource({ scriptPath }) // scriptPath 必填(sources/ 扫描逻辑见 source.js)
// const source = await createIvmSource({ scriptPath, http }) // http 可注入 mock 做离线测试
// await source.kw.getMusicUrl({ songmid: '123' }, '320k').promise
// await source.destroy()
//
// 已知边界(相对桌面端 preload 的差异):
// - lx.request 回调的 resp 不含 raw/bytes(Buffer 无法跨隔离边界,实测脚本未使用)
// - source.request 回调的 resp 不含 raw/bytes(Buffer 无法跨隔离边界,实测脚本未使用)
// - formData 选项不支持(httpFetch 不支持 multipart)
// - updateAlert 事件仅记录到日志,不弹窗
import fs from 'node:fs'
import crypto from 'node:crypto'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'
import { httpFetch } from '../common/request.js'
const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../lx-music-source-v5.js', import.meta.url))
// 从脚本头部的 SERVER_SCRIPT_CONFIG 提取 apiUrl 声明,作为 HTTP 白名单缺省值。
// 兼容裸键(apiUrl:)与 JSON 风格("apiUrl":),并处理 \/ 转义
const extractAllowedOrigins = (script) => {
@@ -51,7 +48,7 @@ const brief = (v, max = 72) => {
return s.length > max ? `${s.slice(0, max)}…(${s.length})` : s
}
// 从脚本头部注释解析 @name/@version 等(对齐 lx.currentScriptInfo)
// 从脚本头部注释解析 @name/@version 等(对齐音源脚本 currentScriptInfo)
const parseScriptInfo = (script, scriptPath) => {
const head = script.slice(0, 2000)
const pick = (key) => {
@@ -69,7 +66,7 @@ const parseScriptInfo = (script, scriptPath) => {
}
}
// 注入隔离环境的胶水层:提供 lx API、定时器、console、Bytes 包装。
// 注入隔离环境的胶水层:提供音源脚本 API(globalThis.lx 为脚本契约名)、定时器、console、Bytes 包装。
// 与 Node 的数据交换一律走 JSON 字符串 / hex 字符串,避免跨隔离传输复杂对象。
const GLUE = `
'use strict'
@@ -188,10 +185,10 @@ function __fromAny(data, enc) {
// ---- console / 定时器桥接 ----
globalThis.console = {
log: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
info: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
warn: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
error: function () { __log('[lx] ' + Array.prototype.map.call(arguments, String).join(' ')) },
log: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
info: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
warn: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
error: function () { __log('[source] ' + Array.prototype.map.call(arguments, String).join(' ')) },
debug: function () {},
}
globalThis.setTimeout = function (fn, ms) {
@@ -221,7 +218,9 @@ globalThis.__fireTimer = function (id) {
try { t.fn.apply(undefined, t.args) } catch (e) { __log('timer error: ' + (e && e.message)) }
}
// ---- lx API(对齐 lx-music-desktop preload.js)----
// ---- 音源脚本 preload API ----
// 注意:globalThis.lx 是第三方脚本依赖的 API 契约名(脚本内部调用 lx.on/source.request 等),
// 属于对外兼容接口而非命名残留,不可改动,否则脚本初始化直接失败。
var __EVENT_NAMES = { request: 'request', inited: 'inited', updateAlert: 'updateAlert' }
var __handlers = {}
globalThis.lx = {
@@ -242,7 +241,7 @@ globalThis.lx = {
var err = __send(String(name), JSON.stringify(data === undefined ? null : data))
return err ? Promise.reject(new Error(err)) : Promise.resolve()
},
request: function (url, options, callback) { return __lxRequest(url, options, callback, __currentDispatch) },
request: function (url, options, callback) { return __sourceRequest(url, options, callback, __currentDispatch) },
utils: {
crypto: {
md5: function (str) { return __md5(str) },
@@ -256,11 +255,11 @@ globalThis.lx = {
},
buffer: {
from: function (data, enc) {
__log('lx.utils.buffer.from(' + (data instanceof __Bytes ? 'Bytes(' + data.length + ')' : typeof data + (data && data.length !== undefined ? '[' + data.length + ']' : '')) + (enc ? ',' + enc : '') + ')')
__log('source.utils.buffer.from(' + (data instanceof __Bytes ? 'Bytes(' + data.length + ')' : typeof data + (data && data.length !== undefined ? '[' + data.length + ']' : '')) + (enc ? ',' + enc : '') + ')')
return __fromAny(data, enc)
},
bufToString: function (buf, format) {
__log('lx.utils.buffer.bufToString(' + (buf && buf.length !== undefined ? buf.length + 'B' : typeof buf) + ',' + (format || 'utf8') + ')')
__log('source.utils.buffer.bufToString(' + (buf && buf.length !== undefined ? buf.length + 'B' : typeof buf) + ',' + (format || 'utf8') + ')')
return buf.toString(format || 'utf8')
},
},
@@ -272,7 +271,7 @@ globalThis.lx = {
}
// 隔离环境内不依赖 ivm 全局:回调统一走「隔离内注册表 + 自增 id」,
// Node 侧通过 jail.get 拿到 __completeHttp 等入口反调
function __lxRequest(url, options, callback, dispatchId) {
function __sourceRequest(url, options, callback, dispatchId) {
options = options || {}
var payload = {
method: options.method || 'get',
@@ -343,9 +342,9 @@ globalThis.__dispatch = function (dispatchId, payloadJson) {
`
/**
* 创建隔离执行的 lx 聚合源
* 创建隔离执行的外部音源
* @param {object} [options]
* @param {string} [options.scriptPath] 原始脚本路径,默认项目根目录 lx-music-source-v5.js
* @param {string} options.scriptPath 音源脚本路径(必填,sources/ 目录扫描逻辑见 source.js)
* @param {Function} [options.http] http 实现,默认项目统一 httpFetch;测试可注入 mock
* @param {number} [options.memoryLimit] 隔离内存上限 MB,默认 256
* @param {number} [options.initTimeout] 等待脚本上报 inited 的超时 ms,默认 10000
@@ -356,8 +355,8 @@ globalThis.__dispatch = function (dispatchId, payloadJson) {
* @param {boolean} [options.allowAllHttp] 放行全部 http 请求(调试用,默认 false)
* @returns {{ env: string, scriptInfo: object, inited: object|null, apis: object, allowedOrigins: string[], destroy: Function }}
*/
export const createIvmLxSource = async({
scriptPath = DEFAULT_SCRIPT_PATH,
export const createIvmSource = async({
scriptPath,
http: httpImpl = httpFetch,
memoryLimit = 256,
initTimeout = 10000,
@@ -371,8 +370,9 @@ export const createIvmLxSource = async({
try {
ivm = (await import('isolated-vm')).default
} catch {
throw new Error('未安装 isolated-vm,无法启用隔离执行兜底:npm i isolated-vm')
throw new Error('未安装 isolated-vm,无法启用隔离执行音源:pnpm add isolated-vm')
}
if (!scriptPath) throw new Error('缺少音源脚本路径 scriptPath(sources/ 目录扫描逻辑见 source.js)')
const script = fs.readFileSync(scriptPath, 'utf8')
const scriptInfo = parseScriptInfo(script, scriptPath)
@@ -382,7 +382,7 @@ export const createIvmLxSource = async({
? logOption
: logOption === false
? () => {}
: (msg) => console.log('[lx-ivm]', msg)
: (msg) => console.log('[source-ivm]', msg)
const scriptOrigins = extractAllowedOrigins(script)
const origins = (allowedOrigins ?? scriptOrigins).map(o => String(o).replace(/\/+$/, ''))
const httpAllowed = (urlStr) => {
@@ -433,12 +433,12 @@ export const createIvmLxSource = async({
await jail.set('__log', new ivm.Callback((msg) => log(String(msg))))
await jail.set('__on', new ivm.Callback((name) => {
state.onRegistered = name
log(`lx.on(${String(name)})`)
log(`source.on(${String(name)})`)
}))
await jail.set('__send', new ivm.Callback((name, dataJson) => {
let data = null
try { data = JSON.parse(dataJson) } catch {}
log(`lx.send(${String(name)}) ${brief(dataJson ?? '')}`)
log(`source.send(${String(name)}) ${brief(dataJson ?? '')}`)
if (name === 'inited') {
state.inited = data
const waiters = state.initWaiters.splice(0)
@@ -458,26 +458,26 @@ export const createIvmLxSource = async({
else p.reject(new Error(parsed.message || '获取失败'))
}))
await jail.set('__md5', new ivm.Callback((str) => {
log(`lx.utils.crypto.md5(${brief(str)})`)
log(`source.utils.crypto.md5(${brief(str)})`)
return crypto.createHash('md5').update(String(str)).digest('hex')
}))
await jail.set('__randomBytesHex', new ivm.Callback((size) => {
log(`lx.utils.crypto.randomBytes(${size})`)
log(`source.utils.crypto.randomBytes(${size})`)
return crypto.randomBytes(size).toString('hex')
}))
await jail.set('__aesEncryptHex', new ivm.Callback((hexBuf, mode, hexKey, hexIv) => {
log(`lx.utils.crypto.aesEncrypt(${Buffer.from(hexBuf, 'hex').length}B, ${mode})`)
log(`source.utils.crypto.aesEncrypt(${Buffer.from(hexBuf, 'hex').length}B, ${mode})`)
const cipher = crypto.createCipheriv(mode, Buffer.from(hexKey, 'hex'), Buffer.from(hexIv, 'hex'))
return Buffer.concat([cipher.update(Buffer.from(hexBuf, 'hex')), cipher.final()]).toString('hex')
}))
await jail.set('__rsaEncryptHex', new ivm.Callback((hexBuf, pemKey) => {
log(`lx.utils.crypto.rsaEncrypt(${Buffer.from(hexBuf, 'hex').length}B, key=${brief(pemKey, 24)})`)
log(`source.utils.crypto.rsaEncrypt(${Buffer.from(hexBuf, 'hex').length}B, key=${brief(pemKey, 24)})`)
let buf = Buffer.from(hexBuf, 'hex')
buf = Buffer.concat([Buffer.alloc(128 - buf.length), buf])
return crypto.publicEncrypt({ key: pemKey, padding: crypto.constants.RSA_NO_PADDING }, buf).toString('hex')
}))
await jail.set('__zlib', new ivm.Callback((opId, op, hex) => {
log(`lx.utils.zlib.${op}(${Buffer.from(hex, 'hex').length}B)`)
log(`source.utils.zlib.${op}(${Buffer.from(hex, 'hex').length}B)`)
const buf = Buffer.from(hex, 'hex')
zlib[op](buf, (err, out) => {
safeApply(zlibDoneRef, [opId, err ? String(err.message) : null, err ? '' : out.toString('hex')])
@@ -488,11 +488,11 @@ export const createIvmLxSource = async({
const opts = JSON.parse(optionsJson)
const method = String(opts.method || 'get').toUpperCase()
if (!httpAllowed(url)) {
log(`✋ lx.request ${method} ${url} → 已阻止(不在白名单)`)
log(`✋ source.request ${method} ${url} → 已阻止(不在白名单)`)
safeApply(completeHttpRef, [cbId, `http blocked by allowlist: ${url}`, null, null])
return reqId
}
log(`→ lx.request ${method} ${brief(url, 120)}`)
log(`→ source.request ${method} ${brief(url, 120)}`)
const startedAt = Date.now()
const options = {
method: opts.method || 'get',
@@ -506,12 +506,12 @@ export const createIvmLxSource = async({
if (!dispatchHttp.has(dispatchId)) dispatchHttp.set(dispatchId, new Set())
dispatchHttp.get(dispatchId).add(requestObj)
requestObj.promise.then(({ statusCode, statusMessage, headers, body }) => {
log(`← lx.request ${method} ${brief(url, 80)} → ${statusCode}(${Date.now() - startedAt}ms)`)
log(`← source.request ${method} ${brief(url, 80)} → ${statusCode}(${Date.now() - startedAt}ms)`)
forgetHttp(reqId, dispatchId, requestObj)
const resp = { statusCode, statusMessage: statusMessage || '', headers: headers || {}, body }
return safeApply(completeHttpRef, [cbId, null, JSON.stringify(resp), JSON.stringify(body === undefined ? null : body)])
}).catch(err => {
log(`← lx.request ${method} ${brief(url, 80)} → 失败(${Date.now() - startedAt}ms)${err?.message ? ' ' + err.message : ''}`)
log(`← source.request ${method} ${brief(url, 80)} → 失败(${Date.now() - startedAt}ms)${err?.message ? ' ' + err.message : ''}`)
forgetHttp(reqId, dispatchId, requestObj)
return safeApply(completeHttpRef, [cbId, String(err && err.message || err), null, null])
})
@@ -639,4 +639,4 @@ export const createIvmLxSource = async({
}
}
export default { createIvmLxSource }
export default { createIvmSource }
+76
View File
@@ -0,0 +1,76 @@
// 外部聚合音源统一入口(isolated-vm 懒加载封装)
// 内部委托 source-ivm.js 在硬隔离沙箱中执行音源脚本。
// 首次调用 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.js'
export const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']
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 = 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 }
let inner = 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
},
})
// 兼容导出:musicSdk/api-source.js 以 createApi(source) 方式接入聚合音源
export const createApi = apiFor
const sourceApi = {}
for (const s of SOURCES) sourceApi[s] = apiFor(s)
// 预热:提前触发脚本加载与初始化(可选调用)
sourceApi.init = getShared
export default sourceApi
+1 -1
View File
@@ -1,4 +1,4 @@
// IPC 事件名(对齐原 lx-music src/common/ipcNames.ts)
// IPC 事件名
export const WIN_MAIN_RENDERER_EVENT_NAME = {
handle_kw_decode_lyric: 'handle_kw_decode_lyric',
+2 -2
View File
@@ -1,6 +1,6 @@
// 歌词解码(替代原 lx-music 主进程 IPC 处理器)
// 歌词解码(原主进程 IPC 处理器的纯 JS 实现)
// - handle_kw_decode_lyric: 酷我歌词(inflate + yeelion XOR + gb18030)
// 算法来源: lx-music-desktop src/main/modules/winMain/rendererEvent/kw_decodeLyric.ts
// 算法来源: kw 桌面端歌词解码实现
// - handle_tx_decode_lyric: QQ音乐歌词(qrc 解码:hex → XOR 偏移 → deflate)
// qrc 算法来源: 开源 qrc 解码器(ZhuYiQing/qrcDecode 纯 JS 版)
+7 -7
View File
@@ -1,9 +1,9 @@
// 酷狗 krc 歌词解码(对齐原 lx-music src/common/utils/lyricUtils/kg.js)
// 酷狗 krc 歌词解码
import zlib from 'node:zlib'
import { decodeName } from '../utils.js'
// https://github.com/lyswhut/lx-music-desktop/issues/296#issuecomment-683285784
// 基于社区通用的 krc 解析思路
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69], 'binary')
const decodeLyric = str => new Promise((resolve, reject) => {
@@ -42,7 +42,7 @@ const parseLyric = str => {
}
}
let i = 0
let lxlyric = str.replace(/\[((\d+),\d+)\].*/g, str => {
let yrc = str.replace(/\[((\d+),\d+)\].*/g, str => {
let result = str.match(/\[((\d+),\d+)\].*/)
let time = parseInt(result[2])
let ms = time % 1000
@@ -58,16 +58,16 @@ const parseLyric = str => {
})
rlyric = rlyric ? rlyric.join('\n') : ''
tlyric = tlyric ? tlyric.join('\n') : ''
lxlyric = lxlyric.replace(/<(\d+,\d+),\d+>/g, '<$1>')
lxlyric = decodeName(lxlyric)
lyric = lxlyric.replace(/<\d+,\d+>/g, '')
yrc = yrc.replace(/<(\d+,\d+),\d+>/g, '<$1>')
yrc = decodeName(yrc)
lyric = yrc.replace(/<\d+,\d+>/g, '')
rlyric = decodeName(rlyric)
tlyric = decodeName(tlyric)
return {
lyric,
tlyric,
rlyric,
lxlyric,
yrc,
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// 请求提示消息(替代原 lx-music 的 message.js)
// 请求提示消息
export const requestMsg = {
cancelRequest: '请求已取消',
+1 -1
View File
@@ -1,4 +1,4 @@
// 替代原 lx-music 的 @common/rendererIpc(Electron ipcRenderer.invoke)
// 渲染器 IPC 桥(替代 Electron ipcRenderer.invoke)
// 服务器环境下 IPC 调用直接映射到本地实现:
// handle_kw_decode_lyric → 酷我歌词解码(见 lyricDecode.js)
// handle_tx_decode_lyric → QQ音乐歌词解码(见 lyricDecode.js)
+1 -1
View File
@@ -1,4 +1,4 @@
// 网络请求模块(替代原 lx-music 渲染器 src/renderer/utils/request.js)
// 网络请求模块
// 接口与原版保持一致:
// httpFetch(url, { method, headers, body, form, timeout, lookup, family })
// → { promise, cancelHttp }
+1 -1
View File
@@ -1,4 +1,4 @@
// 通用工具函数(对齐 lx-music 原版 src/common/utils/common.ts 与 src/renderer/utils/index.ts)
// 通用工具函数
import crypto from 'node:crypto'
+1 -1
View File
@@ -1,5 +1,5 @@
// SDK 根模块 —— musicSdk 内 `import xxx from '../../index.js'` 的解析目标
// 聚合导出通用工具函数(对齐原 lx-music src/renderer/utils/index.ts 的导出)
// 聚合导出通用工具函数
export {
sizeFormate,
+1 -1
View File
@@ -78,7 +78,7 @@ export const COMPONENT_SCHEMAS = {
lyric: { type: 'string', description: '原文歌词(LRC)' },
tlyric: { type: 'string', description: '翻译歌词' },
rlyric: { type: 'string', description: '罗马音歌词(部分平台)' },
lxlyric: { type: 'string', description: '逐字歌词(部分平台)' },
yrc: { type: 'string', description: '逐字歌词(部分平台)' },
},
additionalProperties: true,
required: ['lyric', 'tlyric'],
+5 -3
View File
@@ -1,9 +1,11 @@
// 替代原 lx-music 渲染器的 @renderer/store
// 替代原渲染器的 @renderer/store 模块(保留导入别名所需的接口形态)
// 服务器环境默认使用内置直连接口(apiSource.value === 'internal'),
// 设置 LX_SOURCE_ENABLED=true 可切换至 lx-music-source-v5 聚合源。
// 设置 SOURCE_ENABLED 环境变量(任意非 0/false 值)即启用外部聚合音源。
const enabled = process.env.SOURCE_ENABLED
export const apiSource = {
value: process.env.LX_SOURCE_ENABLED === 'true' ? 'v5' : 'internal',
value: enabled && enabled !== '0' && enabled !== 'false' ? 'source' : 'internal',
}
export const userApi = {
+1 -1
View File
@@ -1,3 +1,3 @@
// 替代原 lx-music 渲染器的 @renderer/utils(SDK 内 `@renderer/utils` 别名目标)
// 渲染器工具模块(SDK 内 `@renderer/utils` 别名目标)
export { decodeName, formatPlayTime, sizeFormate, dateFormat, dateFormat2, formatPlayCount } from '../common/utils.js'
+2
View File
@@ -0,0 +1,2 @@
set LX_SOURCE_ENABLED=true
pnpm run start