fix(plugin): 修正 webpack bundle 沙箱加载 / node polyfills / 内置音源加载 / Settings 导入流程

This commit is contained in:
auto-bot
2026-06-13 23:35:38 +00:00
parent 0856eefa19
commit 51e0e5836c
4 changed files with 1091 additions and 291 deletions

View File

@@ -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') => {

View File

@@ -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,
};
};

View 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 {};
};
}

View File

@@ -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();