fix(plugin): 修正 webpack bundle 沙箱加载 / node polyfills / 内置音源加载 / Settings 导入流程
This commit is contained in:
@@ -361,21 +361,18 @@ const importFromCode = () => {
|
||||
pluginCode.value = '';
|
||||
};
|
||||
|
||||
/** 导入插件代码(兼容 PC/Android 版 CommonJS module.exports 格式) */
|
||||
/** 导入插件代码(兼容 PC/Android 版 CommonJS / webpack bundle 格式) */
|
||||
const importPluginCode = (code: string) => {
|
||||
const module = pluginManager.loadFromCode(code, 'user');
|
||||
if (!module) {
|
||||
alert('插件加载失败,请检查代码格式是否正确(需要 module.exports = {...})');
|
||||
const result = pluginManager.loadFromCode(code, undefined, 'user');
|
||||
if (!result.success) {
|
||||
alert('插件加载失败:' + (result.error || '请检查代码格式是否正确(需要 module.exports = { pluginInfo, ... })'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
pluginManager.saveUserPlugin(code);
|
||||
|
||||
|
||||
refreshPlugins();
|
||||
|
||||
const info = module.pluginInfo?.info || module.info;
|
||||
alert(`音源 "${info?.name || '未知'}" 导入成功!`);
|
||||
|
||||
const info = result.module?.pluginInfo?.info;
|
||||
alert(`音源 "${info?.name || result.pluginId}" 导入成功!`);
|
||||
};
|
||||
|
||||
const applyTheme = (theme: 'dark' | 'light') => {
|
||||
|
||||
@@ -3,82 +3,101 @@ import type { PluginModule } from '../types/plugin';
|
||||
|
||||
/**
|
||||
* 初始化插件系统
|
||||
* 1. 先注册本地默认演示插件
|
||||
* 2. 恢复用户保存的插件
|
||||
* 3. 尝试从 /plugins/ 目录动态加载官方音源插件(如果浏览器环境)
|
||||
* 1) 先注册本地演示插件(离线可用,兜底搜索/播放)
|
||||
* 2) 从 localStorage 恢复用户插件
|
||||
* 3) 从 /plugins/ 目录动态加载官方音源插件(只要部署时有放置)
|
||||
* 返回加载结果,可用于在 UI 层显示提示
|
||||
*/
|
||||
export const initPlugins = async (): Promise<{ total: number; builtins: number; user: number }> => {
|
||||
export interface InitPluginsResult {
|
||||
total: number;
|
||||
builtins: number;
|
||||
user: number;
|
||||
fromNet: number;
|
||||
activePluginId: string;
|
||||
failedBuiltins: string[];
|
||||
}
|
||||
|
||||
const demoPluginModule: PluginModule = {
|
||||
pluginInfo: {
|
||||
info: {
|
||||
id: 'demo',
|
||||
name: '演示音源',
|
||||
description: '本地演示插件(不依赖网络)',
|
||||
version: '1.0.0',
|
||||
},
|
||||
quality: [
|
||||
{ id: 'standard', name: '标准', ui: '标' },
|
||||
],
|
||||
},
|
||||
musicSearch: {
|
||||
search: async (query: string, page: number, limit: number) => {
|
||||
const baseList = [
|
||||
{ songmid: 'demo-1', name: '晴天 (Demo)', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-2', name: '稻香 (Demo)', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-3', name: '七里香 (Demo)', singer: '周杰伦', albumName: '七里香', interval: 299000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-4', name: '告白气球 (Demo)', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-5', name: '夜曲 (Demo)', singer: '周杰伦', albumName: '十一月的萧邦', interval: 237000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-6', name: '平凡之路 (Demo)', singer: '朴树', albumName: '猎户星座', interval: 286000, img: '', source: 'demo' },
|
||||
];
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
const filtered = q ? baseList.filter(s => s.name.toLowerCase().includes(q) || s.singer.toLowerCase().includes(q)) : baseList;
|
||||
const start = Math.max(0, (page - 1) * limit);
|
||||
return {
|
||||
list: filtered.slice(start, start + limit),
|
||||
total: filtered.length,
|
||||
songCount: filtered.length,
|
||||
source: 'demo',
|
||||
};
|
||||
},
|
||||
},
|
||||
getUrl: async (_songId: string, _quality: string) => {
|
||||
// 演示音源使用公共测试 MP3,仅用于界面演示
|
||||
return 'https://www.w3schools.com/html/horse.mp3';
|
||||
},
|
||||
getLyric: async (_songId: string) => {
|
||||
return '[00:00.00]暂无歌词数据\n[00:03.00]演示音源提供占位歌词\n[00:06.00]请切换到官方音源获取真实歌词\n';
|
||||
},
|
||||
};
|
||||
|
||||
export const initPlugins = async (): Promise<InitPluginsResult> => {
|
||||
let builtins = 0;
|
||||
let user = 0;
|
||||
let fromNet = 0;
|
||||
let failedBuiltins: string[] = [];
|
||||
|
||||
// 默认演示插件(用于演示界面,不依赖网络)
|
||||
const demoPlugin: PluginModule = {
|
||||
pluginInfo: {
|
||||
info: { id: 'demo', name: '演示音源', description: '本地演示插件', version: '1.0' },
|
||||
quality: [{ id: 'standard', name: '标准', ui: '标' }],
|
||||
} as any,
|
||||
musicSearch: {
|
||||
search: async (query: string, page: number, limit: number) => {
|
||||
const songs = [
|
||||
{ songmid: 'demo-1', name: '晴天', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-2', name: '稻香', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-3', name: '七里香', singer: '周杰伦', albumName: '七里香', interval: 299000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-4', name: '告白气球', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: '', source: 'demo' },
|
||||
];
|
||||
const q = (query || '').toLowerCase();
|
||||
const filtered = q ? songs.filter(s => s.name.toLowerCase().includes(q) || s.singer.toLowerCase().includes(q)) : songs;
|
||||
const start = Math.max(0, (page - 1) * limit);
|
||||
return { list: filtered.slice(start, start + limit), total: filtered.length };
|
||||
},
|
||||
},
|
||||
getUrl: async () => '',
|
||||
getLyric: async () => '[00:00.00]暂无歌词数据\n[00:05.00]演示内容\n',
|
||||
};
|
||||
pluginManager.registerModule(demoPlugin);
|
||||
// 1) 注册本地演示插件(始终可用,作为兜底)
|
||||
pluginManager.registerModule(demoPluginModule, 'built-in', 'demo');
|
||||
builtins++;
|
||||
|
||||
// 恢复用户插件
|
||||
const userLoaded = pluginManager.loadUserPlugins();
|
||||
user = userLoaded;
|
||||
// 2) 恢复用户插件
|
||||
user = pluginManager.loadUserPlugins();
|
||||
|
||||
// 尝试从 /plugins/ 目录动态加载官方音源插件
|
||||
// 3) 浏览器环境下尝试从 /plugins/ 目录加载官方音源
|
||||
if (typeof window !== 'undefined' && typeof fetch === 'function') {
|
||||
try {
|
||||
const manifest = [
|
||||
{ id: 'wy', fileName: 'zq_wy_v3.js', name: '网易云' },
|
||||
{ id: 'tx', fileName: 'zq_tx_v3-fix1.js', name: 'QQ音乐' },
|
||||
{ id: 'kw', fileName: 'zq_kw_v3-fix1.js', name: '酷我' },
|
||||
{ id: 'kg', fileName: 'zq_kg.js', name: '酷狗' },
|
||||
{ id: 'mg', fileName: 'zq_mg_v3.js', name: '咪咕' },
|
||||
];
|
||||
for (const entry of manifest) {
|
||||
try {
|
||||
if (pluginManager.has(entry.id)) continue;
|
||||
const resp = await fetch('/plugins/' + entry.fileName, { cache: 'no-cache' });
|
||||
if (!resp.ok) continue;
|
||||
const code = await resp.text();
|
||||
pluginManager.loadFromCode(code, 'built-in');
|
||||
builtins++;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
const res = await pluginManager.loadAllBuiltins();
|
||||
fromNet = res.loaded;
|
||||
builtins += res.loaded;
|
||||
failedBuiltins = res.failed;
|
||||
}
|
||||
|
||||
// 恢复上次的激活插件
|
||||
const saved = sessionStorage.getItem('qz-active-plugin');
|
||||
if (saved && pluginManager.get(saved)) {
|
||||
pluginManager.setActivePlugin(saved);
|
||||
} else if (pluginManager.getAll().length > 0) {
|
||||
// 默认优先 wy,没有则选第一个
|
||||
const priority = ['wy', 'tx', 'kw', 'kg', 'mg', 'demo'];
|
||||
const all = pluginManager.getAll();
|
||||
for (const pid of priority) {
|
||||
if (all.find(p => p.id === pid)) {
|
||||
pluginManager.setActivePlugin(pid);
|
||||
// 4) 确保默认激活插件:优先 wy / 首个内置,再是用户的,最后落到 demo
|
||||
const active = pluginManager.getActivePluginId();
|
||||
const priority = ['wy', 'tx', 'kw', 'kg', 'mg', 'demo'];
|
||||
if (!active) {
|
||||
for (const id of priority) {
|
||||
if (pluginManager.has(id)) {
|
||||
pluginManager.setActivePlugin(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { total: builtins + user, builtins, user };
|
||||
return {
|
||||
total: builtins + user,
|
||||
builtins,
|
||||
user,
|
||||
fromNet,
|
||||
activePluginId: pluginManager.getActivePluginId(),
|
||||
failedBuiltins,
|
||||
};
|
||||
};
|
||||
|
||||
522
src/plugins/nodePolyfills.ts
Normal file
522
src/plugins/nodePolyfills.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
// 浏览器端 Node.js 内置模块 polyfill
|
||||
// 用于加载 webpack/ncc 打包的官方音源插件
|
||||
// 提供 http/https (基于 fetch)、stream、util、events、crypto、url、zlib、path、fs、buffer 等
|
||||
|
||||
function createEventEmitter(): any {
|
||||
const listeners: Record<string, Function[]> = {};
|
||||
const self: any = {
|
||||
on(event: string, fn: Function) {
|
||||
(listeners[event] = listeners[event] || []).push(fn);
|
||||
return self;
|
||||
},
|
||||
once(event: string, fn: Function) {
|
||||
const wrapper = (...args: any[]) => {
|
||||
self.off(event, wrapper);
|
||||
fn(...args);
|
||||
};
|
||||
return self.on(event, wrapper);
|
||||
},
|
||||
off(event: string, fn: Function) {
|
||||
if (listeners[event]) {
|
||||
listeners[event] = listeners[event].filter((f: Function) => f !== fn);
|
||||
}
|
||||
return self;
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
const fns = listeners[event] || [];
|
||||
for (const fn of fns) {
|
||||
try { fn.apply(null, args); } catch (_) {}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
addListener: function() { return self.on.apply(self, arguments as any); },
|
||||
removeListener: function() { return self.off.apply(self, arguments as any); },
|
||||
removeAllListeners(event?: string) {
|
||||
if (event) delete listeners[event];
|
||||
else { for (const k in listeners) delete listeners[k]; }
|
||||
return self;
|
||||
},
|
||||
listeners(event: string) { return listeners[event] || []; },
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
function createStreamBase(): any {
|
||||
function Stream() {}
|
||||
Stream.prototype.pipe = function(dest: any) { return dest; };
|
||||
Stream.prototype.on = function(_e: string, _fn: Function) { return this; };
|
||||
Stream.prototype.emit = function() { return true; };
|
||||
return Stream;
|
||||
}
|
||||
|
||||
function createReadable(): any {
|
||||
function Readable() {}
|
||||
Readable.prototype.pipe = function(dest: any) { return dest; };
|
||||
Readable.prototype.on = function() { return this; };
|
||||
Readable.prototype.once = function() { return this; };
|
||||
Readable.prototype.emit = function() { return true; };
|
||||
Readable.prototype.read = function() { return null; };
|
||||
Readable.prototype.pause = function() { return this; };
|
||||
Readable.prototype.resume = function() { return this; };
|
||||
Readable.prototype.push = function() { return this; };
|
||||
return Readable;
|
||||
}
|
||||
|
||||
function createWritable(): any {
|
||||
function Writable() {}
|
||||
Writable.prototype.write = function() { return true; };
|
||||
Writable.prototype.end = function() { return this; };
|
||||
Writable.prototype.on = function() { return this; };
|
||||
Writable.prototype.once = function() { return this; };
|
||||
Writable.prototype.emit = function() { return true; };
|
||||
return Writable;
|
||||
}
|
||||
|
||||
function createTransform(): any {
|
||||
function Transform() {}
|
||||
Transform.prototype.pipe = function(dest: any) { return dest; };
|
||||
Transform.prototype.write = function() { return true; };
|
||||
Transform.prototype.end = function() { return this; };
|
||||
Transform.prototype.on = function() { return this; };
|
||||
Transform.prototype.once = function() { return this; };
|
||||
Transform.prototype.emit = function() { return true; };
|
||||
return Transform;
|
||||
}
|
||||
|
||||
function createPassThrough(): any {
|
||||
function PassThrough() {}
|
||||
PassThrough.prototype.pipe = function(dest: any) { return dest; };
|
||||
PassThrough.prototype.write = function() { return true; };
|
||||
PassThrough.prototype.end = function() { return this; };
|
||||
PassThrough.prototype.on = function() { return this; };
|
||||
PassThrough.prototype.once = function() { return this; };
|
||||
PassThrough.prototype.emit = function() { return true; };
|
||||
return PassThrough;
|
||||
}
|
||||
|
||||
function parseUrl(u: string): any {
|
||||
try {
|
||||
const url = new URL(u, typeof window !== 'undefined' ? window.location.href : 'http://localhost/');
|
||||
return {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
host: url.host,
|
||||
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
|
||||
pathname: url.pathname,
|
||||
path: url.pathname + url.search,
|
||||
search: url.search,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
hash: url.hash,
|
||||
href: url.href,
|
||||
auth: url.username ? url.username + (url.password ? ':' + url.password : '') : '',
|
||||
};
|
||||
} catch {
|
||||
return { protocol: '', hostname: '', host: '', pathname: '', path: '', href: u, port: '80' };
|
||||
}
|
||||
}
|
||||
|
||||
// http/https 模块 - 用 fetch 实现
|
||||
function createHttpModule(isHttps: boolean): any {
|
||||
function request(options: any, callback?: (res: any) => void): any {
|
||||
let url: string;
|
||||
let opts: any = typeof options === 'string' ? {} : { ...options };
|
||||
if (typeof options === 'string') {
|
||||
url = options;
|
||||
} else if (options && typeof options === 'object') {
|
||||
const protocol = opts.protocol || (isHttps ? 'https:' : 'http:');
|
||||
const host = opts.hostname || opts.host || 'localhost';
|
||||
const port = opts.port ? ':' + opts.port : '';
|
||||
const path = opts.path || opts.pathname || '/';
|
||||
url = protocol + '//' + host + port + path;
|
||||
} else {
|
||||
url = String(options);
|
||||
}
|
||||
|
||||
const method = opts.method || 'GET';
|
||||
const headers = opts.headers || {};
|
||||
|
||||
const req: any = createEventEmitter();
|
||||
let requestBodyChunks: Uint8Array[] = [];
|
||||
let ended = false;
|
||||
|
||||
req.write = function(chunk: any) {
|
||||
if (chunk) requestBodyChunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
|
||||
return true;
|
||||
};
|
||||
req.end = function(chunk: any) {
|
||||
if (chunk) req.write(chunk);
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
|
||||
let body: any = undefined;
|
||||
if (method !== 'GET' && method !== 'HEAD' && requestBodyChunks.length > 0) {
|
||||
if (requestBodyChunks.length === 1) body = requestBodyChunks[0];
|
||||
else {
|
||||
const total = requestBodyChunks.reduce((a, b) => a + b.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of requestBodyChunks) { merged.set(c, offset); offset += c.length; }
|
||||
body = merged;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: opts.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
} as any).then((resp: any) => {
|
||||
const res: any = createEventEmitter();
|
||||
res.statusCode = resp.status;
|
||||
res.statusMessage = resp.statusText;
|
||||
res.headers = {};
|
||||
try {
|
||||
resp.headers.forEach((v: string, k: string) => { res.headers[k.toLowerCase()] = v; });
|
||||
} catch {}
|
||||
res.setEncoding = function() { return res; };
|
||||
res.pipe = function(dest: any) {
|
||||
resp.arrayBuffer().then((buf: ArrayBuffer) => {
|
||||
if (dest && typeof dest.write === 'function') dest.write(new Uint8Array(buf));
|
||||
if (dest && typeof dest.end === 'function') dest.end();
|
||||
res.emit('end');
|
||||
}).catch((err: Error) => res.emit('error', err));
|
||||
return dest;
|
||||
};
|
||||
res.text = () => resp.text();
|
||||
res.json = () => resp.json();
|
||||
res.body = resp.body;
|
||||
if (callback) callback(res);
|
||||
req.emit('response', res);
|
||||
|
||||
// 如果没有 callback,等待 body 然后发射 data/end 事件
|
||||
if (!callback) {
|
||||
resp.arrayBuffer().then((buf: ArrayBuffer) => {
|
||||
res.emit('data', new Uint8Array(buf));
|
||||
res.emit('end');
|
||||
}).catch((err: Error) => res.emit('error', err));
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
req.emit('error', err);
|
||||
});
|
||||
return req;
|
||||
};
|
||||
req.abort = function() { req.emit('abort'); };
|
||||
req.setTimeout = function() { return req; };
|
||||
req.setHeader = function(name: string, value: string) { (headers as any)[name.toLowerCase()] = value; return req; };
|
||||
req.getHeader = function(name: string) { return (headers as any)[name.toLowerCase()]; };
|
||||
req.removeHeader = function(name: string) { delete (headers as any)[name.toLowerCase()]; };
|
||||
|
||||
// 如果用户立即调用 req.end(),我们需要在 next tick 开始 - 但实际上用户必须自己调用 end
|
||||
return req;
|
||||
}
|
||||
|
||||
function get(options: any, callback?: (res: any) => void): any {
|
||||
const req = request(options, callback);
|
||||
req.end();
|
||||
return req;
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
get,
|
||||
createServer: () => ({ listen: () => {}, close: () => {} }),
|
||||
Agent: function() { this.maxSockets = 50; },
|
||||
globalAgent: { maxSockets: 50 },
|
||||
STATUS_CODES: {},
|
||||
};
|
||||
}
|
||||
|
||||
// util 模块
|
||||
const utilModule: any = {
|
||||
inspect: (obj: any) => {
|
||||
try { return JSON.stringify(obj); } catch { return String(obj); }
|
||||
},
|
||||
format: function() { return Array.prototype.slice.call(arguments).join(' '); },
|
||||
inherits: function(ctor: any, superCtor: any) {
|
||||
if (!superCtor || !superCtor.prototype) return;
|
||||
ctor.super_ = superCtor;
|
||||
const origProto = ctor.prototype;
|
||||
ctor.prototype = Object.create(superCtor.prototype, {
|
||||
constructor: { value: ctor, enumerable: false, writable: true, configurable: true },
|
||||
});
|
||||
// 保留原型上原有的属性
|
||||
if (origProto) {
|
||||
for (const k in origProto) {
|
||||
if (Object.prototype.hasOwnProperty.call(origProto, k)) {
|
||||
ctor.prototype[k] = origProto[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
promisify: function(fn: any) {
|
||||
return function() {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
return new Promise((resolve, reject) => {
|
||||
args.push((err: any, ...rest: any[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rest.length === 1 ? rest[0] : rest);
|
||||
});
|
||||
try { fn.apply(null, args); } catch (e) { reject(e); }
|
||||
});
|
||||
};
|
||||
},
|
||||
callbackify: function(fn: any) {
|
||||
return function() {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
const cb = args.pop();
|
||||
fn.apply(null, args).then((r: any) => cb(null, r)).catch((e: Error) => cb(e));
|
||||
};
|
||||
},
|
||||
TextDecoder: typeof (globalThis as any).TextDecoder !== 'undefined' ? (globalThis as any).TextDecoder : function(this: any) { this.decode = function() { return ''; }; },
|
||||
TextEncoder: typeof (globalThis as any).TextEncoder !== 'undefined' ? (globalThis as any).TextEncoder : function(this: any) { this.encode = function(s: string) { return s; }; },
|
||||
types: { isDate: (x: any) => x instanceof Date, isRegExp: (x: any) => x instanceof RegExp, isError: (x: any) => x instanceof Error, isArray: Array.isArray, isFunction: (x: any) => typeof x === 'function' },
|
||||
};
|
||||
|
||||
// crypto 模块
|
||||
const cryptoModule: any = {
|
||||
createHash: function(algo: string) {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
|
||||
digest: function(encoding?: string) {
|
||||
// 简化:不做真正的哈希,返回一个稳定的伪随机值
|
||||
const s = algo + ':' + chunks.join('');
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 31).toString(16).padStart(16, '0');
|
||||
if (encoding === 'hex') return hex;
|
||||
if (encoding === 'base64') return btoa(hex);
|
||||
return hex;
|
||||
},
|
||||
};
|
||||
},
|
||||
createHmac: function(algo: string, key: any) {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
|
||||
digest: function(encoding?: string) {
|
||||
const s = 'hmac:' + algo + ':' + key + ':' + chunks.join('');
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 17).toString(16).padStart(16, '0');
|
||||
if (encoding === 'hex') return hex;
|
||||
if (encoding === 'base64') return btoa(hex);
|
||||
return hex;
|
||||
},
|
||||
};
|
||||
},
|
||||
randomBytes: function(n: number, cb?: (err: Error | null, buf: any) => void) {
|
||||
const arr = new Uint8Array(n);
|
||||
if (typeof (globalThis as any).crypto !== 'undefined' && (globalThis as any).crypto.getRandomValues) {
|
||||
(globalThis as any).crypto.getRandomValues(arr);
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) arr[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
const buf: any = {
|
||||
buffer: arr,
|
||||
length: n,
|
||||
toString: function(enc?: string) {
|
||||
if (enc === 'hex') {
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) s += arr[i].toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
if (enc === 'base64') {
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
return String.fromCharCode.apply(null, arr as any);
|
||||
},
|
||||
};
|
||||
if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; }
|
||||
return buf;
|
||||
},
|
||||
randomUUID: function() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// 完整的 polyfill 注册表
|
||||
export function createNodePolyfills(): Record<string, any> {
|
||||
const streamMod: any = {
|
||||
Stream: createStreamBase(),
|
||||
Readable: createReadable(),
|
||||
Writable: createWritable(),
|
||||
Duplex: createTransform(),
|
||||
Transform: createTransform(),
|
||||
PassThrough: createPassThrough(),
|
||||
pipeline: (_a: any, b: any, cb?: () => void) => { if (cb) cb(); return b; },
|
||||
finished: (_s: any, cb: () => void) => { cb(); },
|
||||
};
|
||||
streamMod.Stream.prototype = Object.create(createEventEmitter());
|
||||
streamMod.Readable.prototype = Object.create(streamMod.Stream.prototype);
|
||||
streamMod.Writable.prototype = Object.create(streamMod.Stream.prototype);
|
||||
streamMod.Duplex.prototype = Object.create(streamMod.Stream.prototype);
|
||||
streamMod.Transform.prototype = Object.create(streamMod.Duplex.prototype);
|
||||
streamMod.PassThrough.prototype = Object.create(streamMod.Transform.prototype);
|
||||
|
||||
const bufferModule: any = {
|
||||
from: (x: any) => {
|
||||
if (x instanceof Uint8Array) return x;
|
||||
if (typeof x === 'string') return new TextEncoder().encode(x);
|
||||
if (Array.isArray(x)) return new Uint8Array(x);
|
||||
return new Uint8Array(x || 0);
|
||||
},
|
||||
alloc: (n: number) => new Uint8Array(n),
|
||||
allocUnsafe: (n: number) => new Uint8Array(n),
|
||||
isBuffer: () => false,
|
||||
concat: (arrs: any[]) => {
|
||||
const total = arrs.reduce((a, b) => a + (b?.length || 0), 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of arrs) { if (c) { merged.set(c, offset); offset += c.length; } }
|
||||
return merged;
|
||||
},
|
||||
byteLength: (x: any) => typeof x === 'string' ? new TextEncoder().encode(x).length : (x?.length || 0),
|
||||
compare: (_a: any, _b: any) => 0,
|
||||
isEncoding: () => true,
|
||||
};
|
||||
|
||||
return {
|
||||
http: createHttpModule(false),
|
||||
https: createHttpModule(true),
|
||||
http2: createHttpModule(true),
|
||||
stream: streamMod,
|
||||
util: utilModule,
|
||||
events: { EventEmitter: function() { return createEventEmitter(); } },
|
||||
crypto: cryptoModule,
|
||||
url: {
|
||||
parse: parseUrl,
|
||||
format: (o: any) => o.href || `${o.protocol}//${o.host}${o.path || o.pathname || ''}`,
|
||||
resolve: (_from: string, to: string) => { try { return new URL(to, _from).href; } catch { return to; } },
|
||||
URL: typeof (globalThis as any).URL !== 'undefined' ? (globalThis as any).URL : function() {},
|
||||
URLSearchParams: typeof (globalThis as any).URLSearchParams !== 'undefined' ? (globalThis as any).URLSearchParams : function() {},
|
||||
},
|
||||
zlib: {
|
||||
gunzipSync: (d: any) => d,
|
||||
inflateSync: (d: any) => d,
|
||||
deflateSync: (d: any) => d,
|
||||
gzipSync: (d: any) => d,
|
||||
gunzip: (d: any, cb: (err: Error | null, out: any) => void) => { setTimeout(() => cb(null, d), 0); },
|
||||
inflate: (d: any, cb: (err: Error | null, out: any) => void) => { setTimeout(() => cb(null, d), 0); },
|
||||
createGunzip: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; },
|
||||
createInflate: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; },
|
||||
createGzip: () => { const p = Object.create(streamMod.Transform.prototype); p.write = () => true; p.end = function() { this.emit && this.emit('end'); return this; }; return p; },
|
||||
},
|
||||
path: {
|
||||
join: function() { return Array.prototype.slice.call(arguments).filter(Boolean).join('/').replace(/\/+/g, '/'); },
|
||||
resolve: function() { return Array.prototype.slice.call(arguments).filter(Boolean).join('/'); },
|
||||
dirname: (p: string) => String(p).split('/').slice(0, -1).join('/') || '/',
|
||||
basename: (p: string, ext?: string) => { const base = String(p).split('/').pop() || ''; return ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base; },
|
||||
extname: (p: string) => { const m = String(p).match(/\.[^.]*$/); return m ? m[0] : ''; },
|
||||
sep: '/', delimiter: ':',
|
||||
parse: (p: string) => { const parts = String(p).split('/'); return { root: '/', dir: parts.slice(0, -1).join('/'), base: parts[parts.length - 1], ext: '', name: parts[parts.length - 1].replace(/\.[^.]*$/, '') }; },
|
||||
normalize: (p: string) => p,
|
||||
isAbsolute: (p: string) => p && p[0] === '/',
|
||||
relative: (_from: string, to: string) => to,
|
||||
},
|
||||
fs: {
|
||||
readFileSync: () => '',
|
||||
writeFileSync: () => {},
|
||||
existsSync: () => false,
|
||||
createWriteStream: () => { const s = Object.create(streamMod.Writable.prototype); s.write = () => true; s.end = function() { this.emit && this.emit('finish'); return this; }; return s; },
|
||||
createReadStream: () => { const s = Object.create(streamMod.Readable.prototype); s.pipe = (d: any) => { s.emit && s.emit('end'); return d; }; return s; },
|
||||
readFile: (_path: any, enc: any, cb: (err: Error | null, data: any) => void) => { if (typeof enc === 'function') cb = enc; setTimeout(() => cb(null, ''), 0); },
|
||||
writeFile: (_p: any, _d: any, cb?: (err: Error | null) => void) => { if (typeof cb === 'function') setTimeout(() => cb(null), 0); },
|
||||
mkdirSync: () => {},
|
||||
statSync: () => ({ isDirectory: () => false, isFile: () => true, size: 0, mtime: new Date() }),
|
||||
unlinkSync: () => {},
|
||||
promises: {
|
||||
readFile: async () => '',
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
},
|
||||
},
|
||||
os: {
|
||||
platform: () => 'browser',
|
||||
type: () => 'Browser',
|
||||
release: () => '1.0',
|
||||
tmpdir: () => '/tmp',
|
||||
homedir: () => '/',
|
||||
EOL: '\n',
|
||||
arch: () => 'x64',
|
||||
cpus: () => [],
|
||||
totalmem: () => 0,
|
||||
freemem: () => 0,
|
||||
},
|
||||
assert: {
|
||||
ok: function() {},
|
||||
equal: function() {},
|
||||
deepEqual: function() {},
|
||||
strictEqual: function() {},
|
||||
throws: function() {},
|
||||
notEqual: function() {},
|
||||
ifError: function() {},
|
||||
},
|
||||
dns: {
|
||||
lookup: (_host: string, cb: (err: Error | null, addr: string, family: number) => void) => { setTimeout(() => cb(null, '127.0.0.1', 4), 0); },
|
||||
resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); },
|
||||
},
|
||||
buffer: bufferModule,
|
||||
Buffer: bufferModule,
|
||||
string_decoder: {
|
||||
StringDecoder: function() {
|
||||
this.write = function(x: any) { return typeof x === 'string' ? x : new TextDecoder().decode(x); };
|
||||
this.end = function(x: any) { return x ? this.write(x) : ''; };
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
parse: (s: string) => {
|
||||
const out: Record<string, string> = {};
|
||||
const q = String(s || '').replace(/^\?/, '');
|
||||
if (!q) return out;
|
||||
for (const pair of q.split('&')) {
|
||||
const [k, v] = pair.split('=');
|
||||
if (k) out[decodeURIComponent(k)] = v !== undefined ? decodeURIComponent(v) : '';
|
||||
}
|
||||
return out;
|
||||
},
|
||||
stringify: (obj: any) => Object.entries(obj || {}).map(([k, v]) => encodeURIComponent(String(k)) + '=' + encodeURIComponent(String(v))).join('&'),
|
||||
},
|
||||
tty: { isatty: () => false, ReadStream: function() {}, WriteStream: function() {} },
|
||||
net: {
|
||||
createServer: () => ({ listen: () => {}, close: () => {}, on: function() { return this; } }),
|
||||
createConnection: () => createEventEmitter(),
|
||||
Socket: function() { return createEventEmitter(); },
|
||||
},
|
||||
tls: {
|
||||
connect: () => createEventEmitter(),
|
||||
TLSSocket: function() { return createEventEmitter(); },
|
||||
createSecureContext: () => ({}),
|
||||
},
|
||||
child_process: {
|
||||
spawn: () => createEventEmitter(),
|
||||
exec: (_cmd: string, cb: (err: Error | null, stdout: string, stderr: string) => void) => setTimeout(() => cb(null, '', ''), 0),
|
||||
execSync: () => '',
|
||||
},
|
||||
cluster: { isWorker: false, isMaster: true, fork: function() {}, on: function() { return this; } },
|
||||
module: { Module: function() {} },
|
||||
vm: { runInNewContext: (code: string, sandbox: any) => { const fn = new Function(...Object.keys(sandbox || {}), code); return fn(...Object.values(sandbox || {})); } },
|
||||
perf_hooks: { performance: typeof (globalThis as any).performance !== 'undefined' ? (globalThis as any).performance : { now: () => Date.now() } },
|
||||
console,
|
||||
};
|
||||
}
|
||||
|
||||
// 为给定的 webpack bundle 代码提供完整的沙箱环境
|
||||
export function createSandboxRequire(): (id: string) => any {
|
||||
const polyfills = createNodePolyfills();
|
||||
return function(id: string): any {
|
||||
if (polyfills[id]) return polyfills[id];
|
||||
// 处理子路径,如 stream/transform, crypto/hash 等
|
||||
const base = id.split('/')[0];
|
||||
if (polyfills[base]) return polyfills[base];
|
||||
return {};
|
||||
};
|
||||
}
|
||||
@@ -1,327 +1,588 @@
|
||||
import type {
|
||||
PluginModule,
|
||||
PluginFullInfo,
|
||||
PluginSearchResult,
|
||||
UrlResponse,
|
||||
} from '../types/plugin';
|
||||
import { createSandboxRequire } from './nodePolyfills';
|
||||
import type { PluginFullInfo, PluginSearchResult, UrlResponse, PluginModule } from '../types/plugin';
|
||||
|
||||
// ===== 官方音源插件注册表 =====
|
||||
export interface BuiltinPluginDef {
|
||||
id: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
isFixVersion: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const BUILTIN_PLUGINS: BuiltinPluginDef[] = [
|
||||
{ id: 'wy', platform: 'wy', name: 'ZQ芸 (网易云)', fileName: 'zq_wy_v3.js', isFixVersion: false },
|
||||
{ id: 'tx', platform: 'tx', name: 'ZQ秋 (QQ音乐)', fileName: 'zq_tx_v3-fix1.js', isFixVersion: true },
|
||||
{ id: 'kw', platform: 'kw', name: 'ZQ我 (酷我)', fileName: 'zq_kw_v3-fix1.js', isFixVersion: true },
|
||||
{ id: 'kg', platform: 'kg', name: 'ZQ驹 (酷狗)', fileName: 'zq_kg.js', isFixVersion: false },
|
||||
{ id: 'mg', platform: 'mg', name: 'ZQ咕 (咪咕)', fileName: 'zq_mg_v3.js', isFixVersion: false },
|
||||
{ id: 'wy', platform: 'wy', name: '网易云音乐', fileName: 'zq_wy_v3.js', description: '官方网易云音源插件 v3' },
|
||||
{ id: 'tx', platform: 'tx', name: 'QQ 音乐', fileName: 'zq_tx_v3-fix1.js', description: '官方 QQ 音乐音源插件 v3' },
|
||||
{ id: 'kw', platform: 'kw', name: '酷我音乐', fileName: 'zq_kw_v3-fix1.js', description: '官方酷我音源插件 v3' },
|
||||
{ id: 'kg', platform: 'kg', name: '酷狗音乐', fileName: 'zq_kg.js', description: '官方酷狗音源插件' },
|
||||
{ id: 'mg', platform: 'mg', name: '咪咕音乐', fileName: 'zq_mg_v3.js', description: '官方咪咕音源插件 v3' },
|
||||
];
|
||||
|
||||
const PLUGIN_FILE_BASE = '/plugins/';
|
||||
// 支持的插件源 URL 前缀 - 按优先级排序
|
||||
const PLUGIN_SOURCE_PREFIXES: string[] = [
|
||||
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载)
|
||||
];
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
quality: any[];
|
||||
version: string;
|
||||
module: PluginModule;
|
||||
source: 'built-in' | 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* PluginManager 负责:
|
||||
* 1) 加载并执行 webpack bundle / CommonJS 风格插件代码
|
||||
* 2) 维护已加载插件清单、激活插件
|
||||
* 3) 作为 musicSearch / getUrl / getLyric 的统一入口
|
||||
* 4) localStorage 持久化用户插件
|
||||
*/
|
||||
export class PluginManager {
|
||||
private plugins: Map<string, PluginModule> = new Map();
|
||||
private activePluginId: string = 'wy';
|
||||
private codeCache: Map<string, string> = new Map();
|
||||
private loadingPromises: Map<string, Promise<PluginModule | null>> = new Map();
|
||||
private plugins: Map<string, LoadedPlugin> = new Map();
|
||||
private activePluginId: string = '';
|
||||
private userPluginCodes: Map<string, string> = new Map();
|
||||
private loadingPromises: Map<string, Promise<boolean>> = new Map();
|
||||
|
||||
constructor() {
|
||||
// 从 localStorage 恢复用户插件代码
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '{}');
|
||||
for (const [id, code] of Object.entries(saved)) {
|
||||
this.userPluginCodes.set(id, code as string);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
|
||||
// 恢复激活插件
|
||||
const savedActive = sessionStorage.getItem('qz-active-plugin');
|
||||
if (savedActive) this.activePluginId = savedActive;
|
||||
}
|
||||
|
||||
// ========== 查询类 ==========
|
||||
getAll(): PluginFullInfo[] {
|
||||
return Array.from(this.plugins.values()).map(p => this.extractInfo(p)).filter(Boolean) as PluginFullInfo[];
|
||||
const result: PluginFullInfo[] = [];
|
||||
for (const [id, lp] of this.plugins.entries()) {
|
||||
result.push({
|
||||
id,
|
||||
name: lp.name || id,
|
||||
description: lp.description || '',
|
||||
quality: lp.quality,
|
||||
version: lp.version,
|
||||
source: lp.source,
|
||||
} as any);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getAllBuiltinDefs(): BuiltinPluginDef[] {
|
||||
return BUILTIN_PLUGINS;
|
||||
}
|
||||
|
||||
get(id: string): PluginModule | undefined {
|
||||
return this.plugins.get(id);
|
||||
return BUILTIN_PLUGINS.slice();
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.plugins.has(id);
|
||||
}
|
||||
|
||||
getActivePlugin(): PluginModule | undefined {
|
||||
return this.plugins.get(this.activePluginId);
|
||||
get(id: string): PluginModule | undefined {
|
||||
return this.plugins.get(id)?.module;
|
||||
}
|
||||
|
||||
getActivePluginId(): string {
|
||||
return this.activePluginId;
|
||||
}
|
||||
|
||||
getActivePlugin(): PluginModule | undefined {
|
||||
return this.plugins.get(this.activePluginId)?.module;
|
||||
}
|
||||
|
||||
setActivePlugin(id: string): boolean {
|
||||
if (this.plugins.has(id)) {
|
||||
this.activePluginId = id;
|
||||
try { sessionStorage.setItem('qz-active-plugin', id); } catch {}
|
||||
try { sessionStorage.setItem('qz-active-plugin', id); } catch { /* noop */ }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getQualityList(id: string): { id: string; name: string; ui: string }[] {
|
||||
getQualityList(id: string): { id: string; name: string; ui?: string }[] {
|
||||
const mod = this.plugins.get(id);
|
||||
const qs = mod?.pluginInfo?.quality;
|
||||
if (qs && qs.length > 0) return qs;
|
||||
if (mod && mod.quality && mod.quality.length > 0) return mod.quality;
|
||||
return [
|
||||
{ id: 'standard', name: '标准音质', ui: '标' },
|
||||
{ id: 'exhigh', name: '高品音质', ui: 'HQ' },
|
||||
{ id: 'lossless', name: '无损音质', ui: 'SQ' },
|
||||
{ id: 'standard', name: '标准', ui: '标' },
|
||||
{ id: 'exhigh', name: '极高', ui: 'HQ' },
|
||||
{ id: 'lossless', name: '无损', ui: 'SQ' },
|
||||
{ id: 'hires', name: 'Hi-Res', ui: 'HR' },
|
||||
];
|
||||
}
|
||||
|
||||
private extractInfo(mod: PluginModule): PluginFullInfo | null {
|
||||
const info = mod.pluginInfo?.info;
|
||||
if (!info) return null;
|
||||
return {
|
||||
...info,
|
||||
quality: mod.pluginInfo?.quality,
|
||||
env: mod.pluginInfo?.env,
|
||||
ext: mod.pluginInfo?.ext,
|
||||
supportFunc: mod.pluginInfo?.supportFunc,
|
||||
source: (mod as any).__source || 'built-in',
|
||||
};
|
||||
}
|
||||
|
||||
registerModule(mod: PluginModule, source: 'built-in' | 'user' = 'built-in'): string | null {
|
||||
const info = mod.pluginInfo?.info;
|
||||
if (!info || !info.id) return null;
|
||||
(mod as any).__source = source;
|
||||
this.plugins.set(info.id, mod);
|
||||
if (!sessionStorage.getItem('qz-active-plugin')) {
|
||||
this.activePluginId = info.id;
|
||||
// ========== 插件代码加载 ==========
|
||||
/**
|
||||
* 从 JS 代码字符串加载插件。
|
||||
* 兼容:
|
||||
* 1) module.exports = { pluginInfo, musicSearch, getUrl, getLyric } (CommonJS)
|
||||
* 2) webpack 打包的 UMD/IIFE bundle(会通过赋值到 this / globalThis 或直接调用安装函数)
|
||||
* 3) 通过 window.__plugin_install__ 安装
|
||||
*/
|
||||
loadFromCode(code: string, preferredId?: string, source: 'built-in' | 'user' = 'built-in')
|
||||
: { success: boolean; pluginId: string; module?: PluginModule; error?: string } {
|
||||
if (!code || typeof code !== 'string') {
|
||||
return { success: false, pluginId: '', error: '代码为空' };
|
||||
}
|
||||
return info.id;
|
||||
}
|
||||
|
||||
unregister(id: string): boolean {
|
||||
if (this.activePluginId === id) {
|
||||
const remaining = Array.from(this.plugins.keys()).filter(k => k !== id);
|
||||
this.activePluginId = remaining[0] || '';
|
||||
let module: PluginModule | null = null;
|
||||
try {
|
||||
module = this._executePluginCode(code);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
pluginId: '',
|
||||
error: '执行插件代码时出现异常: ' + (err?.message || String(err)),
|
||||
};
|
||||
}
|
||||
return this.plugins.delete(id);
|
||||
|
||||
if (!module || typeof module !== 'object') {
|
||||
return { success: false, pluginId: '', error: '插件未导出对象' };
|
||||
}
|
||||
if (!module.pluginInfo || !module.pluginInfo.info) {
|
||||
return { success: false, pluginId: '', error: '插件缺少 pluginInfo.info 字段' };
|
||||
}
|
||||
|
||||
const id = preferredId || module.pluginInfo.info.id || 'anonymous';
|
||||
const name = module.pluginInfo.info.name || id;
|
||||
const version = module.pluginInfo.info.version || '1.0';
|
||||
const description = module.pluginInfo.info.description || '';
|
||||
const quality = (module.pluginInfo.quality as any[]) || [];
|
||||
|
||||
this.plugins.set(id, {
|
||||
id, name, description, quality, version, module, source,
|
||||
});
|
||||
|
||||
if (!this.activePluginId) this.activePluginId = id;
|
||||
|
||||
// 用户插件代码持久化
|
||||
if (source === 'user') {
|
||||
this.userPluginCodes.set(id, code);
|
||||
this._persistUserPlugins();
|
||||
}
|
||||
|
||||
return { success: true, pluginId: id, module };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JS 代码字符串加载插件
|
||||
* 支持两种格式:
|
||||
* 1. module.exports = { ... } (用户手写的简单插件)
|
||||
* 2. webpack bundle (官方原版,包含 __webpack_modules__ / __nccwpck_require__)
|
||||
* 通过 registerModule 注册一个直接构造好的 PluginModule(用于 demo / 默认插件)
|
||||
*/
|
||||
loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null {
|
||||
if (!code || typeof code !== 'string') return null;
|
||||
try {
|
||||
const sandbox: any = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
globalThis: typeof window !== 'undefined' ? window : globalThis,
|
||||
window: typeof window !== 'undefined' ? window : undefined,
|
||||
console,
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
Promise, JSON, Math, Date, Array, Object,
|
||||
btoa: typeof btoa !== 'undefined' ? btoa : undefined,
|
||||
atob: typeof atob !== 'undefined' ? atob : undefined,
|
||||
};
|
||||
sandbox.global = sandbox.globalThis;
|
||||
registerModule(mod: PluginModule, source: 'built-in' | 'user' = 'built-in', preferredId?: string): boolean {
|
||||
if (!mod || !mod.pluginInfo || !mod.pluginInfo.info) return false;
|
||||
const id = preferredId || mod.pluginInfo.info.id || 'demo';
|
||||
this.plugins.set(id, {
|
||||
id,
|
||||
name: mod.pluginInfo.info.name || id,
|
||||
description: mod.pluginInfo.info.description || '',
|
||||
quality: (mod.pluginInfo.quality as any[]) || [],
|
||||
version: mod.pluginInfo.info.version || '1.0',
|
||||
module: mod,
|
||||
source,
|
||||
});
|
||||
if (!this.activePluginId) this.activePluginId = id;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 用 Function 构造沙盒环境执行代码
|
||||
const argNames = Object.keys(sandbox);
|
||||
const argValues = argNames.map(k => sandbox[k]);
|
||||
const fn = new Function(...argNames, code + '\n;return module.exports;');
|
||||
const result = fn(...argValues);
|
||||
const mod = (result && typeof result === 'object' && result.exports) ? result.exports : result;
|
||||
if (!mod || typeof mod !== 'object') return null;
|
||||
if (!mod.pluginInfo) return null;
|
||||
(mod as any).__source = source;
|
||||
const id = this.registerModule(mod, source);
|
||||
if (!id) return null;
|
||||
return mod;
|
||||
} catch (err) {
|
||||
console.error('[PluginManager] loadFromCode 执行失败:', err);
|
||||
/** 卸载并删除用户插件 */
|
||||
removeUserPlugin(id: string): boolean {
|
||||
const lp = this.plugins.get(id);
|
||||
if (lp && lp.source === 'user') {
|
||||
this.plugins.delete(id);
|
||||
}
|
||||
this.userPluginCodes.delete(id);
|
||||
this._persistUserPlugins();
|
||||
if (this.activePluginId === id) this._pickFallbackActive();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 按 id 卸载任何插件(含内置) */
|
||||
unregister(id: string): boolean {
|
||||
const existed = this.plugins.delete(id);
|
||||
this.userPluginCodes.delete(id);
|
||||
this._persistUserPlugins();
|
||||
if (this.activePluginId === id) this._pickFallbackActive();
|
||||
return existed;
|
||||
}
|
||||
|
||||
/** 把用户上传的 JS 代码保存一份 */
|
||||
saveUserPlugin(code: string, id?: string): string | null {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '{}');
|
||||
const realId = id || ('user_' + Date.now());
|
||||
saved[realId] = code;
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
|
||||
this.userPluginCodes.set(realId, code);
|
||||
return realId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从远程 URL 下载插件代码并加载 */
|
||||
async loadFromUrl(url: string, source: 'built-in' | 'user' = 'built-in'): Promise<PluginModule | null> {
|
||||
if (this.codeCache.has(url)) {
|
||||
return this.loadFromCode(this.codeCache.get(url)!, source);
|
||||
}
|
||||
if (this.loadingPromises.has(url)) {
|
||||
return this.loadingPromises.get(url)!;
|
||||
}
|
||||
const promise = (async () => {
|
||||
/** 从 localStorage 重新加载用户插件,返回成功加载数 */
|
||||
loadUserPlugins(): number {
|
||||
let loaded = 0;
|
||||
for (const [id, code] of this.userPluginCodes.entries()) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', mode: 'cors' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const code = await res.text();
|
||||
this.codeCache.set(url, code);
|
||||
return this.loadFromCode(code, source);
|
||||
} catch (err) {
|
||||
console.error('[PluginManager] 从 URL 加载插件失败:', url, err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
this.loadingPromises.set(url, promise);
|
||||
const result = await promise;
|
||||
this.loadingPromises.delete(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 加载单个内置插件(按文件名) */
|
||||
async loadBuiltin(def: BuiltinPluginDef): Promise<PluginModule | null> {
|
||||
if (this.has(def.id)) return this.get(def.id)!;
|
||||
const url = PLUGIN_FILE_BASE + def.fileName;
|
||||
return this.loadFromUrl(url, 'built-in');
|
||||
}
|
||||
|
||||
/** 加载所有内置插件(并行),相同 platform 只保留 -fix 版 */
|
||||
async loadAllBuiltins(): Promise<number> {
|
||||
const tasks = BUILTIN_PLUGINS.map(def => this.loadBuiltin(def));
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const loaded = results.filter(r => r.status === 'fulfilled' && r.value).length;
|
||||
if (!this.getActivePlugin() && this.plugins.size > 0) {
|
||||
const first = this.plugins.keys().next();
|
||||
if (first.value) this.activePluginId = first.value;
|
||||
const res = this.loadFromCode(code, id, 'user');
|
||||
if (res.success) loaded++;
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 搜索:使用指定或激活的插件进行搜索 */
|
||||
async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise<PluginSearchResult> {
|
||||
const pid = preferredPluginId || this.activePluginId || Array.from(this.plugins.keys())[0];
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod) return { list: [], total: 0, error: '当前没有可用插件' };
|
||||
private _persistUserPlugins() {
|
||||
try {
|
||||
const toSave: Record<string, string> = {};
|
||||
this.userPluginCodes.forEach((c, k) => { toSave[k] = c; });
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(toSave));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
let searchFn: ((q: string, p: number, l: number) => Promise<PluginSearchResult>) | undefined;
|
||||
private _pickFallbackActive() {
|
||||
if (this.plugins.size === 0) {
|
||||
this.activePluginId = '';
|
||||
sessionStorage.removeItem('qz-active-plugin');
|
||||
return;
|
||||
}
|
||||
// 优先 wy,否则取第一个
|
||||
if (this.plugins.has('wy')) {
|
||||
this.activePluginId = 'wy';
|
||||
} else {
|
||||
this.activePluginId = this.plugins.keys().next().value || '';
|
||||
}
|
||||
try { sessionStorage.setItem('qz-active-plugin', this.activePluginId); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// ========== 网络下载加载 ==========
|
||||
async loadFromUrl(url: string, preferredId?: string, source: 'built-in' | 'user' = 'built-in'): Promise<boolean> {
|
||||
const cacheKey = url;
|
||||
if (this.loadingPromises.has(cacheKey)) {
|
||||
return this.loadingPromises.get(cacheKey)!;
|
||||
}
|
||||
const promise = (async (): Promise<boolean> => {
|
||||
try {
|
||||
const resp = await fetch(url, { cache: 'no-store' });
|
||||
if (!resp.ok) return false;
|
||||
const code = await resp.text();
|
||||
const result = this.loadFromCode(code, preferredId, source);
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.warn('[PluginManager] loadFromUrl 失败:', url, err);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
this.loadingPromises.set(cacheKey, promise);
|
||||
const result = await promise;
|
||||
this.loadingPromises.delete(cacheKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadBuiltin(def: BuiltinPluginDef): Promise<boolean> {
|
||||
if (this.has(def.id)) return true;
|
||||
for (const prefix of PLUGIN_SOURCE_PREFIXES) {
|
||||
if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) {
|
||||
// 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文)
|
||||
const lp = this.plugins.get(def.id);
|
||||
if (lp) {
|
||||
lp.name = def.name;
|
||||
if (def.description) lp.description = def.description;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 并行加载所有内置音源插件,返回加载情况 */
|
||||
async loadAllBuiltins(): Promise<{ loaded: number; total: number; failed: string[] }> {
|
||||
const failed: string[] = [];
|
||||
const tasks = BUILTIN_PLUGINS.map(def =>
|
||||
this.loadBuiltin(def).then(ok => {
|
||||
if (!ok) failed.push(def.id);
|
||||
return ok;
|
||||
})
|
||||
);
|
||||
const results = await Promise.allSettled(tasks);
|
||||
let loaded = 0;
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value) loaded++;
|
||||
}
|
||||
if (!this.activePluginId && this.plugins.size > 0) {
|
||||
this._pickFallbackActive();
|
||||
}
|
||||
return { loaded, total: BUILTIN_PLUGINS.length, failed };
|
||||
}
|
||||
|
||||
// ========== 插件 API 统一入口 ==========
|
||||
async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise<PluginSearchResult> {
|
||||
const pid = preferredPluginId || this.activePluginId;
|
||||
if (!pid) return { list: [], total: 0, error: '未激活音源插件' };
|
||||
const lp = this.plugins.get(pid);
|
||||
if (!lp) return { list: [], total: 0, error: '音源插件未加载: ' + pid };
|
||||
const mod = lp.module;
|
||||
let searchFn: ((q: string, p: number, l: number) => Promise<any>) | undefined;
|
||||
if (mod.musicSearch && typeof (mod.musicSearch as any).search === 'function') {
|
||||
searchFn = (mod.musicSearch as any).search.bind(mod.musicSearch);
|
||||
} else if (typeof mod.musicSearch === 'function') {
|
||||
searchFn = mod.musicSearch as any;
|
||||
searchFn = mod.musicSearch;
|
||||
}
|
||||
if (!searchFn) return { list: [], total: 0, error: '插件不支持搜索' };
|
||||
|
||||
try {
|
||||
const result = await searchFn(query, page, limit);
|
||||
if (!result || !Array.isArray(result.list)) {
|
||||
return { list: [], total: 0, error: '搜索返回格式异常' };
|
||||
}
|
||||
(result as any).__pluginId = pid;
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] 插件 ${pid} 搜索失败:`, err);
|
||||
return { list: [], total: 0, error: (err as Error).message };
|
||||
if (!result) return { list: [], total: 0, error: '搜索返回空' };
|
||||
const list = Array.isArray(result.list) ? result.list : [];
|
||||
return {
|
||||
list,
|
||||
total: result.total ?? result.songCount ?? list.length,
|
||||
songCount: result.songCount,
|
||||
allPage: result.allPage,
|
||||
source: pid,
|
||||
error: undefined,
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('[PluginManager] search 失败:', err);
|
||||
return { list: [], total: 0, error: err?.message || '搜索失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取歌曲 URL:根据 song.source 优先尝试 → 多插件回退 */
|
||||
async getSongUrl(song: { id?: string; songmid?: string; source?: string }, quality?: string): Promise<UrlResponse> {
|
||||
const songId = song.songmid || song.id;
|
||||
if (!songId) return { success: false, error: '歌曲缺少 id' };
|
||||
|
||||
const candidateIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source);
|
||||
if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId);
|
||||
for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid);
|
||||
|
||||
const lastErr: string[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const pid of candidateIds) {
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod || typeof mod.getUrl !== 'function') continue;
|
||||
const lp = this.plugins.get(pid);
|
||||
if (!lp || typeof lp.module.getUrl !== 'function') continue;
|
||||
try {
|
||||
const q = quality || this.getQualityList(pid)[0]?.id || 'standard';
|
||||
const result = await mod.getUrl(songId, q);
|
||||
const q = quality || (lp.quality && lp.quality[0]?.id) || 'standard';
|
||||
const result = await lp.module.getUrl(songId, q);
|
||||
let url: string | undefined;
|
||||
if (typeof result === 'string') {
|
||||
url = result;
|
||||
} else if (result && typeof result === 'object') {
|
||||
if (typeof result === 'string') url = result;
|
||||
else if (result && typeof result === 'object') {
|
||||
url = (result as any).url || (result as any).data?.url || (result as any).data;
|
||||
if (typeof url !== 'string') url = undefined;
|
||||
}
|
||||
if (url && url.length > 0 && /^(https?:)?\/\//i.test(url)) {
|
||||
if (url && typeof url === 'string' && /^(https?:)?\/\//i.test(url)) {
|
||||
return { success: true, url, pluginId: pid };
|
||||
}
|
||||
if (url && typeof url === 'string' && url.startsWith('//')) {
|
||||
return { success: true, url: 'https:' + url, pluginId: pid };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, err);
|
||||
lastErr.push(`${pid}: ${(err as Error).message}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[PluginManager] ${pid} getUrl 失败:`, err);
|
||||
errors.push(pid + ':' + (err?.message || '失败'));
|
||||
}
|
||||
}
|
||||
return { success: false, error: '所有插件都未能获取播放地址(' + lastErr.join('; ') + ')' };
|
||||
return { success: false, error: '所有插件都未能获取播放地址 (' + errors.join('; ') + ')' };
|
||||
}
|
||||
|
||||
/** 获取歌词 */
|
||||
async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any }> {
|
||||
async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any; error?: string }> {
|
||||
const songId = song.songmid || song.id;
|
||||
if (!songId) return { format: null, raw: null };
|
||||
|
||||
if (!songId) return { format: null, raw: null, error: '歌曲缺少 id' };
|
||||
const candidateIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source);
|
||||
if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId);
|
||||
for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid);
|
||||
|
||||
for (const pid of candidateIds) {
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod || typeof mod.getLyric !== 'function') continue;
|
||||
const lp = this.plugins.get(pid);
|
||||
if (!lp || typeof lp.module.getLyric !== 'function') continue;
|
||||
try {
|
||||
const raw = await mod.getLyric(songId);
|
||||
const raw = await lp.module.getLyric(songId);
|
||||
if (raw === null || raw === undefined) continue;
|
||||
const format = detectLyricFormat(raw);
|
||||
return { format, raw };
|
||||
} catch (err) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, err);
|
||||
} catch (err: any) {
|
||||
console.warn(`[PluginManager] ${pid} getLyric 失败:`, err);
|
||||
}
|
||||
}
|
||||
return { format: null, raw: null };
|
||||
}
|
||||
|
||||
/** localStorage 持久化用户插件 */
|
||||
saveUserPlugin(code: string): void {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
saved.push(code);
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
|
||||
} catch {}
|
||||
}
|
||||
// ========== 内部:执行插件代码 ==========
|
||||
private _executePluginCode(code: string): PluginModule | null {
|
||||
const moduleObj: any = { exports: {} };
|
||||
const requireFn = createSandboxRequire();
|
||||
|
||||
loadUserPlugins(): number {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
let loaded = 0;
|
||||
for (const code of saved) {
|
||||
const m = this.loadFromCode(code, 'user');
|
||||
if (m) loaded++;
|
||||
const processObj: any = {
|
||||
env: { NODE_ENV: 'production' },
|
||||
version: 'v20.0.0',
|
||||
platform: 'browser',
|
||||
arch: 'x64',
|
||||
argv: [],
|
||||
title: 'node',
|
||||
browser: true,
|
||||
cwd: () => '/',
|
||||
chdir: () => { /* noop */ },
|
||||
on: function () { return this; },
|
||||
once: function () { return this; },
|
||||
emit: function () { return true; },
|
||||
off: function () { return this; },
|
||||
addListener: function () { return this; },
|
||||
removeListener: function () { return this; },
|
||||
removeAllListeners: function () { return this; },
|
||||
versions: { node: '20.0.0', v8: '11.3.244.8-node.16' },
|
||||
exit: function () { /* noop */ },
|
||||
nextTick: (fn: () => void) => setTimeout(fn, 0),
|
||||
};
|
||||
|
||||
const textEnc = typeof (globalThis as any).TextEncoder !== 'undefined' ? new (globalThis as any).TextEncoder() : null;
|
||||
const textDec = typeof (globalThis as any).TextDecoder !== 'undefined' ? new (globalThis as any).TextDecoder('utf-8') : null;
|
||||
|
||||
const BufferCtor: any = function (arg: any) {
|
||||
if (typeof arg === 'string') return textEnc ? textEnc.encode(arg) : new Uint8Array(arg.length);
|
||||
if (arg instanceof Uint8Array) return arg;
|
||||
if (typeof arg === 'number') return new Uint8Array(arg);
|
||||
return new Uint8Array(arg || 0);
|
||||
};
|
||||
BufferCtor.from = (x: any) => {
|
||||
if (x instanceof Uint8Array) return new Uint8Array(x);
|
||||
if (typeof x === 'string') return textEnc ? textEnc.encode(x) as Uint8Array : new Uint8Array(0);
|
||||
if (Array.isArray(x)) return new Uint8Array(x as any);
|
||||
return new Uint8Array(x || 0);
|
||||
};
|
||||
BufferCtor.alloc = (n: number) => new Uint8Array(n);
|
||||
BufferCtor.allocUnsafe = (n: number) => new Uint8Array(n);
|
||||
BufferCtor.allocUnsafeSlow = (n: number) => new Uint8Array(n);
|
||||
BufferCtor.isBuffer = (x: any) => x instanceof Uint8Array || (x && x.constructor && x.constructor.name === 'Buffer');
|
||||
BufferCtor.byteLength = (x: any) => typeof x === 'string'
|
||||
? (textEnc ? textEnc.encode(x).length : x.length)
|
||||
: (x?.length || 0);
|
||||
BufferCtor.isEncoding = () => true;
|
||||
BufferCtor.concat = (arrs: any[]) => {
|
||||
const total = arrs.reduce((a: number, b: any) => a + (b?.length || 0), 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of arrs) { if (c) { merged.set(c, offset); offset += c.length; } }
|
||||
return merged;
|
||||
};
|
||||
const toOrig = (Uint8Array.prototype as any).toString;
|
||||
(BufferCtor as any).prototype = Object.create(Uint8Array.prototype);
|
||||
(BufferCtor as any).prototype.toString = function (encoding?: string) {
|
||||
try {
|
||||
if (encoding === 'base64') {
|
||||
let s = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < this.length; i += chunk) {
|
||||
s += String.fromCharCode.apply(null, Array.from(this.subarray(i, i + chunk)));
|
||||
}
|
||||
return typeof btoa !== 'undefined' ? btoa(s) : s;
|
||||
}
|
||||
if (encoding === 'hex') {
|
||||
let s = '';
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
s += this[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
if (encoding === 'utf8' || encoding === 'utf-8' || !encoding) {
|
||||
return textDec ? textDec.decode(this) : String.fromCharCode.apply(null, Array.from(this));
|
||||
}
|
||||
return textDec ? textDec.decode(this) : String.fromCharCode.apply(null, Array.from(this));
|
||||
} catch {
|
||||
return toOrig ? toOrig.call(this) : '';
|
||||
}
|
||||
return loaded;
|
||||
} catch { return 0; }
|
||||
}
|
||||
};
|
||||
|
||||
removeUserPlugin(id: string): void {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
const filtered = saved.filter((code: string) => {
|
||||
try {
|
||||
const sandbox: any = { module: { exports: {} }, exports: {}, console, Promise, JSON, Math, Date, setTimeout, clearTimeout };
|
||||
const fn = new Function(...Object.keys(sandbox), code + '\n;return module.exports;');
|
||||
const result = fn(...Object.keys(sandbox).map(k => sandbox[k]));
|
||||
const mod = result?.exports || result;
|
||||
return mod?.pluginInfo?.info?.id !== id;
|
||||
} catch { return true; }
|
||||
});
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(filtered));
|
||||
} catch {}
|
||||
const fakeGlobal: any = {
|
||||
process: processObj,
|
||||
Buffer: BufferCtor,
|
||||
console,
|
||||
setTimeout: setTimeout as any,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate: function (...rest: any[]) { return setTimeout(rest[0], 0); },
|
||||
clearImmediate: (id: any) => clearTimeout(id),
|
||||
Promise,
|
||||
JSON,
|
||||
Math,
|
||||
Date,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Symbol,
|
||||
Error,
|
||||
TypeError,
|
||||
Uint8Array,
|
||||
Uint16Array,
|
||||
Uint32Array,
|
||||
Int8Array,
|
||||
Int16Array,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
TextDecoder: (globalThis as any).TextDecoder,
|
||||
TextEncoder: (globalThis as any).TextEncoder,
|
||||
fetch: (globalThis as any).fetch,
|
||||
Headers: (globalThis as any).Headers,
|
||||
Request: (globalThis as any).Request,
|
||||
Response: (globalThis as any).Response,
|
||||
crypto: (globalThis as any).crypto,
|
||||
performance: (globalThis as any).performance,
|
||||
};
|
||||
fakeGlobal.self = fakeGlobal;
|
||||
fakeGlobal.window = fakeGlobal;
|
||||
fakeGlobal.global = fakeGlobal;
|
||||
fakeGlobal.globalThis = fakeGlobal;
|
||||
fakeGlobal.root = fakeGlobal;
|
||||
|
||||
const wrapperCode =
|
||||
'(function(module,exports,require,__dirname,__filename,process,Buffer,console,setTimeout,clearTimeout,setInterval,clearInterval,Promise,JSON,Math,Date,Array,Object,global,globalThis){' +
|
||||
code +
|
||||
'\n;return module.exports;})';
|
||||
const fn: any = (new Function('return ' + wrapperCode))();
|
||||
|
||||
const runArgs: any[] = [
|
||||
moduleObj,
|
||||
moduleObj.exports,
|
||||
requireFn,
|
||||
'/',
|
||||
'/plugin.js',
|
||||
processObj,
|
||||
BufferCtor,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
Promise,
|
||||
JSON,
|
||||
Math,
|
||||
Date,
|
||||
Array,
|
||||
Object,
|
||||
fakeGlobal,
|
||||
fakeGlobal,
|
||||
];
|
||||
|
||||
const result = fn.apply(fakeGlobal, runArgs);
|
||||
const mod = (result && typeof result === 'object' && (result as any).exports)
|
||||
? (result as any).exports
|
||||
: result;
|
||||
|
||||
if (!mod || (typeof mod === 'object' && Object.keys(mod).length === 0)) {
|
||||
const direct = (fakeGlobal.__plugin_install__ as any) || (fakeGlobal as any).plugin;
|
||||
if (direct && typeof direct === 'object' && direct.pluginInfo) return direct as PluginModule;
|
||||
}
|
||||
|
||||
return mod as PluginModule;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLyricFormat(raw: any): string | null {
|
||||
// ========== 歌词格式识别 ==========
|
||||
function detectLyricFormat(raw: any): string | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw === 'string') {
|
||||
const s = raw.trim();
|
||||
@@ -329,28 +590,29 @@ export function detectLyricFormat(raw: any): string | null {
|
||||
if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml';
|
||||
if (/<\s*\d+[::]\d+/.test(s)) return 'qrc';
|
||||
if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) return 'lrc';
|
||||
if (s.charAt(0) === '{' || s.charAt(0) === '[') {
|
||||
if (s.startsWith('{') || s.startsWith('[')) {
|
||||
try {
|
||||
const obj = JSON.parse(s);
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
if (obj.yrc || obj.lrclib || obj.klyric) return 'yrc';
|
||||
if (obj.lrc) return 'lrc';
|
||||
if (obj.ttml) return 'ttml';
|
||||
if (obj.qrc) return 'qrc';
|
||||
if ((obj as any).yrc || (obj as any).lrclib || (obj as any).klyric) return 'yrc';
|
||||
if ((obj as any).lrc || (obj as any).lyric) return 'lrc';
|
||||
if ((obj as any).ttml) return 'ttml';
|
||||
if ((obj as any).qrc) return 'qrc';
|
||||
return 'json';
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc';
|
||||
if (raw.lrc) return 'lrc';
|
||||
if (raw.ttml) return 'ttml';
|
||||
if (raw.qrc) return 'qrc';
|
||||
if ((raw as any).yrc || (raw as any).lrclib || (raw as any).klyric) return 'yrc';
|
||||
if ((raw as any).lrc) return 'lrc';
|
||||
if ((raw as any).ttml) return 'ttml';
|
||||
if ((raw as any).qrc) return 'qrc';
|
||||
return 'json';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
export const pluginManager = new PluginManager();
|
||||
|
||||
Reference in New Issue
Block a user