From 0856eefa19846b90e01e5dc3645ebb7af45ccc0a Mon Sep 17 00:00:00 2001 From: auto-bot Date: Sat, 13 Jun 2026 18:23:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20-=20=E6=94=AF=E6=8C=81=E5=AE=98=E6=96=B9?= =?UTF-8?q?=20v3=20=E9=9F=B3=E6=BA=90=20(wy/tx/kw/kg/mg)=20+=20=E5=A4=9A?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=AD=8C=E8=AF=8D=E8=A7=A3=E6=9E=90=20+=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E5=8A=A0=E8=BD=BD=20webpack=20bundle=20+=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E4=BB=8E=20/plugins/=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=AE=98=E6=96=B9=E9=9F=B3=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 48 ++ src/plugins/impl/defaultPlugin.ts | 4 +- src/plugins/init.ts | 89 +++- src/plugins/pluginManager.ts | 482 +++++++++++-------- src/types/plugin.ts | 79 ++- src/utils/lyricUtil.ts | 771 +++++++++++------------------- 6 files changed, 737 insertions(+), 736 deletions(-) diff --git a/install.sh b/install.sh index 510ac9f..fb9290a 100755 --- a/install.sh +++ b/install.sh @@ -96,6 +96,54 @@ fi log_info "项目构建成功!" echo "" +# ========= 下载官方音源插件 (v3) ========= +log_info "正在下载官方音源插件到 dist/plugins/..." +PLUGIN_DIR="$INSTALL_DIR/dist/plugins" +mkdir -p "$PLUGIN_DIR" +FILE_SERVER="http://171.80.3.149:5244" +SHARE_CODE="music" +PLUGIN_FILES=( + "zq_wy_v3.js" + "zq_tx_v3-fix1.js" + "zq_kw_v3-fix1.js" + "zq_kg.js" + "zq_mg_v3.js" +) +DOWNLOADED=0 +for fname in "${PLUGIN_FILES[@]}"; do + target="$PLUGIN_DIR/$fname" + # 通过 fs/get API 获取 raw_url (JSON 中 data.raw_url) + api_resp=$(curl -s -X POST "$FILE_SERVER/api/fs/get" \ + -H "Content-Type: application/json" \ + -d "{\"path\":\"/@s/c6VNt7hG/音源/QZ-Music_v2/官方/v3/$fname\",\"password\":\"$SHARE_CODE\"}" \ + --max-time 30 2>/dev/null || echo "") + + raw_url="" + if [ -n "$api_resp" ]; then + # 使用 python3 解析 JSON 取 data.raw_url;若失败则回退 sd 直链 + raw_url=$(echo "$api_resp" | python3 -c "import sys,json; +try: + d=json.load(sys.stdin) + print(d.get('data',{}).get('raw_url','')) +except: + print('')" 2>/dev/null) + fi + + if [ -z "$raw_url" ]; then + raw_url="$FILE_SERVER/sd/c6VNt7hG/音源/QZ-Music_v2/官方/v3/$fname?pwd=$SHARE_CODE" + fi + + if curl -sL "$raw_url" --max-time 120 -o "$target" -w '%{http_code}' 2>/dev/null | grep -qE "^2"; then + DOWNLOADED=$((DOWNLOADED + 1)) + size=$(wc -c < "$target" 2>/dev/null | tr -d ' ') + log_info " ✓ $fname ($size bytes)" + else + log_warn " ✗ $fname 下载失败" + fi +done +log_info "音源插件下载完成: $DOWNLOADED/${#PLUGIN_FILES[@]}" +echo "" + # ========= 部署 systemd 服务 ========= log_info "正在部署 systemd 服务: $SERVICE_NAME" diff --git a/src/plugins/impl/defaultPlugin.ts b/src/plugins/impl/defaultPlugin.ts index 9255b64..6011d98 100644 --- a/src/plugins/impl/defaultPlugin.ts +++ b/src/plugins/impl/defaultPlugin.ts @@ -57,9 +57,9 @@ module.exports = { `; export const defaultPluginModule: PluginModule = (function() { - var module = { exports: {} }; + var module: { exports: any } = { exports: {} }; eval(defaultPluginCode); - return module.exports; + return module.exports as PluginModule; })(); export { defaultPluginCode }; diff --git a/src/plugins/init.ts b/src/plugins/init.ts index 16ae58c..3821693 100644 --- a/src/plugins/init.ts +++ b/src/plugins/init.ts @@ -1,15 +1,84 @@ -import { pluginManager, defaultPluginModule } from './index'; +import { pluginManager } from './pluginManager'; +import type { PluginModule } from '../types/plugin'; -export const initPlugins = async (): Promise => { - // 注册内置默认插件 - pluginManager.registerModule(defaultPluginModule); +/** + * 初始化插件系统 + * 1. 先注册本地默认演示插件 + * 2. 恢复用户保存的插件 + * 3. 尝试从 /plugins/ 目录动态加载官方音源插件(如果浏览器环境) + */ +export const initPlugins = async (): Promise<{ total: number; builtins: number; user: number }> => { + let builtins = 0; + let user = 0; - // 恢复用户保存的插件 - pluginManager.loadUserPlugins(); + // 默认演示插件(用于演示界面,不依赖网络) + 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); + builtins++; - // 恢复上次选择的插件 - const savedPlugin = sessionStorage.getItem('qz-active-plugin'); - if (savedPlugin && pluginManager.get(savedPlugin)) { - pluginManager.setActivePlugin(savedPlugin); + // 恢复用户插件 + const userLoaded = pluginManager.loadUserPlugins(); + user = userLoaded; + + // 尝试从 /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 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); + break; + } + } + } + + return { total: builtins + user, builtins, user }; }; diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 281bec7..1226cb6 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -1,293 +1,353 @@ -import type { PluginFullInfo, PluginModule, PluginSearchResult, UrlResponse } from '../types/plugin'; +import type { + PluginModule, + PluginFullInfo, + PluginSearchResult, + UrlResponse, +} from '../types/plugin'; -/** - * QZMusic Web 插件管理器 - * 兼容 PC/Android 原版的 CommonJS 插件格式: - * module.exports = { - * pluginInfo: { info: { id, name, ... }, quality: [...] }, - * musicSearch: { search: (query, page, limit) => result }, - * getUrl: (id, quality) => url, - * getLyric: (id) => lyric_string_or_object - * } - */ -class PluginManager { +export interface BuiltinPluginDef { + id: string; + platform: string; + name: string; + fileName: string; + isFixVersion: boolean; +} + +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 }, +]; + +const PLUGIN_FILE_BASE = '/plugins/'; + +export class PluginManager { private plugins: Map = new Map(); - private activePluginId: string = ''; + private activePluginId: string = 'wy'; + private codeCache: Map = new Map(); + private loadingPromises: Map> = new Map(); - /** 注册插件模块(直接传入已解析的模块对象) */ - registerModule(module: PluginModule): boolean { - const info = this.extractInfo(module); - if (!info || !info.id) { - console.error('[PluginManager] 插件缺少有效的 info 或 pluginInfo.info.id'); - return false; - } - this.plugins.set(info.id, module); - if (!this.activePluginId) { - this.activePluginId = info.id; - } - return true; - } - - /** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */ - loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null { - try { - const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`; - const mod = (new Function(wrapped))() as PluginModule; - if (!mod) { - console.error('[PluginManager] 插件代码执行后返回空'); - return null; - } - if (mod.pluginInfo?.info) { - (mod.pluginInfo.info as any).__source = source; - } - if (mod.info) { - (mod.info as any).__source = source; - } - this.registerModule(mod); - return mod; - } catch (e) { - console.error('[PluginManager] 加载插件代码失败:', e); - return null; - } - } - - /** 卸载插件 */ - unregister(id: string): boolean { - if (this.activePluginId === id) { - const remaining = this.getAll(); - this.activePluginId = remaining.length > 0 && remaining[0].id !== id ? remaining[0].id : ''; - } - return this.plugins.delete(id); - } - - /** 获取所有插件信息 */ getAll(): PluginFullInfo[] { - return Array.from(this.plugins.values()).map(p => this.extractInfo(p)!).filter(Boolean); + return Array.from(this.plugins.values()).map(p => this.extractInfo(p)).filter(Boolean) as PluginFullInfo[]; + } + + getAllBuiltinDefs(): BuiltinPluginDef[] { + return BUILTIN_PLUGINS; } - /** 获取插件模块 */ get(id: string): PluginModule | undefined { return this.plugins.get(id); } - /** 获取当前激活的插件 */ + has(id: string): boolean { + return this.plugins.has(id); + } + getActivePlugin(): PluginModule | undefined { return this.plugins.get(this.activePluginId); } - /** 设置激活插件 */ + getActivePluginId(): string { + return this.activePluginId; + } + setActivePlugin(id: string): boolean { if (this.plugins.has(id)) { this.activePluginId = id; - sessionStorage.setItem('qz-active-plugin', id); + try { sessionStorage.setItem('qz-active-plugin', id); } catch {} return true; } return false; } - /** 获取当前激活插件 ID */ - getActivePluginId(): string { - return this.activePluginId; - } - - /** 获取指定插件的音质列表 */ getQualityList(id: string): { id: string; name: string; ui: string }[] { const mod = this.plugins.get(id); - if (mod?.pluginInfo?.quality && mod.pluginInfo.quality.length > 0) { - return mod.pluginInfo.quality; - } + const qs = mod?.pluginInfo?.quality; + if (qs && qs.length > 0) return qs; return [ - { id: 'standard', name: '标准', ui: 'SQ' }, - { id: 'high', name: '高品', ui: 'HQ' }, - { id: 'hires', name: '无损', ui: 'HR' }, + { id: 'standard', name: '标准音质', ui: '标' }, + { id: 'exhigh', name: '高品音质', ui: 'HQ' }, + { id: 'lossless', name: '无损音质', ui: 'SQ' }, + { id: 'hires', name: 'Hi-Res', ui: 'HR' }, ]; } - /** 搜索(兼容 PC 版 musicSearch.search 接口) */ - async search(query: string, page: number, limit: number): Promise { - const plugin = this.getActivePlugin(); - if (!plugin?.musicSearch?.search) { - return { list: [], total: 0, error: '当前插件不支持搜索' }; + 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; } + 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] || ''; + } + return this.plugins.delete(id); + } + + /** + * 从 JS 代码字符串加载插件 + * 支持两种格式: + * 1. module.exports = { ... } (用户手写的简单插件) + * 2. webpack bundle (官方原版,包含 __webpack_modules__ / __nccwpck_require__) + */ + loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null { + if (!code || typeof code !== 'string') return null; try { - const result = await plugin.musicSearch.search(query, page, limit); + 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; + + // 用 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); + return null; + } + } + + /** 从远程 URL 下载插件代码并加载 */ + async loadFromUrl(url: string, source: 'built-in' | 'user' = 'built-in'): Promise { + 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 () => { + 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 { + 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 { + 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; + } + return loaded; + } + + /** 搜索:使用指定或激活的插件进行搜索 */ + async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise { + const pid = preferredPluginId || this.activePluginId || Array.from(this.plugins.keys())[0]; + const mod = this.plugins.get(pid); + if (!mod) return { list: [], total: 0, error: '当前没有可用插件' }; + + let searchFn: ((q: string, p: number, l: number) => Promise) | 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; + } + 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: '插件未返回正确的搜索结果格式' }; + return { list: [], total: 0, error: '搜索返回格式异常' }; } + (result as any).__pluginId = pid; return result; - } catch (e) { - console.error('[PluginManager] 搜索失败:', e); - return { list: [], total: 0, error: (e as Error).message }; + } catch (err) { + console.error(`[PluginManager] 插件 ${pid} 搜索失败:`, err); + return { list: [], total: 0, error: (err as Error).message }; } } - /** 获取歌曲 URL(兼容 PC 版 getUrl 接口) - * priority: song.source 指定的插件 → 当前激活插件 → 所有插件轮流试 - */ - async getSongUrl(song: { id: string; source?: string; quality?: string }, preferQuality?: string): Promise { - const pluginIds: string[] = []; - if (song.source && this.plugins.has(song.source)) { - pluginIds.push(song.source); - } - if (this.activePluginId && !pluginIds.includes(this.activePluginId)) { - pluginIds.push(this.activePluginId); - } - for (const pid of this.plugins.keys()) { - if (!pluginIds.includes(pid)) pluginIds.push(pid); - } + /** 获取歌曲 URL:根据 song.source 优先尝试 → 多插件回退 */ + async getSongUrl(song: { id?: string; songmid?: string; source?: string }, quality?: string): Promise { + const songId = song.songmid || song.id; + if (!songId) return { success: false, error: '歌曲缺少 id' }; - const quality = preferQuality || song.quality || 'standard'; + 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 pluginIds) { - const plugin = this.plugins.get(pid); - if (!plugin?.getUrl) continue; + const lastErr: string[] = []; + for (const pid of candidateIds) { + const mod = this.plugins.get(pid); + if (!mod || typeof mod.getUrl !== 'function') continue; try { - const url = await plugin.getUrl(song.id, quality); - if (typeof url === 'string' && url.length > 0) { - if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:') || url.startsWith('blob:')) { - return { success: true, url }; - } - // 一些插件可能返回相对路径,加 https: - if (url.startsWith('//')) { - return { success: true, url: 'https:' + url }; - } + const q = quality || this.getQualityList(pid)[0]?.id || 'standard'; + const result = await mod.getUrl(songId, q); + let url: string | undefined; + 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; } - } catch (e) { - console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, e); + if (url && url.length > 0 && /^(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}`); } } - return { success: false, error: '所有插件都无法获取这首歌曲的播放地址' }; + return { success: false, error: '所有插件都未能获取播放地址(' + lastErr.join('; ') + ')' }; } - /** 获取歌词(兼容 PC 版 getLyric 接口) - * 返回:{ format: 'lrc' | 'ttml' | 'qrc' | 'yrc' | 'json' | 'text' | null, raw: any } - */ - async getLyric(song: { id: string; source?: string }): Promise<{ format: string | null; raw: any }> { - const pluginIds: string[] = []; - if (song.source && this.plugins.has(song.source)) { - pluginIds.push(song.source); - } - if (this.activePluginId && !pluginIds.includes(this.activePluginId)) { - pluginIds.push(this.activePluginId); - } - for (const pid of this.plugins.keys()) { - if (!pluginIds.includes(pid)) pluginIds.push(pid); - } + /** 获取歌词 */ + async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any }> { + const songId = song.songmid || song.id; + if (!songId) return { format: null, raw: null }; - for (const pid of pluginIds) { - const plugin = this.plugins.get(pid); - if (!plugin?.getLyric) continue; + 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; try { - const raw = await plugin.getLyric(song.id); - if (raw == null) continue; + const raw = await mod.getLyric(songId); + if (raw === null || raw === undefined) continue; const format = detectLyricFormat(raw); - if (format) { - return { format, raw }; - } - // 就算识别不到格式,也返回原始内容 - return { format: format, raw }; - } catch (e) { - console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, e); + return { format, raw }; + } catch (err) { + console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, err); } } return { format: null, raw: null }; } - hasPlugins(): boolean { - return this.plugins.size > 0; - } - - /** 从 localStorage 恢复用户插件 */ - loadUserPlugins(): void { - try { - const saved = localStorage.getItem('qz-user-plugins'); - if (saved) { - const pluginList: string[] = JSON.parse(saved); - pluginList.forEach(code => { - this.loadFromCode(code, 'user'); - }); - } - } catch (e) { - console.error('[PluginManager] 恢复用户插件失败:', e); - } - } - - /** 保存用户插件 code 到 localStorage */ + /** localStorage 持久化用户插件 */ saveUserPlugin(code: string): void { try { - const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); + const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); saved.push(code); localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); - } catch (e) { - console.error('[PluginManager] 保存用户插件失败:', e); - } + } catch {} + } + + 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++; + } + return loaded; + } catch { return 0; } } - /** 删除用户插件(按 id 匹配) */ removeUserPlugin(id: string): void { try { - const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - const filtered = saved.filter(code => { + const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); + const filtered = saved.filter((code: string) => { try { - const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`; - const mod = (new Function(wrapped))(); - const info = mod?.pluginInfo?.info || mod?.info; - return info?.id !== id; - } catch { - return true; - } + 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 (e) { - console.error('[PluginManager] 删除用户插件失败:', e); - } - } - - /** 提取插件信息(兼容 PC 版两种格式) */ - private extractInfo(module: PluginModule): PluginFullInfo | null { - if (module.pluginInfo?.info) { - return { - ...module.pluginInfo.info, - quality: module.pluginInfo.quality, - source: (module.pluginInfo.info as any).__source || 'built-in', - }; - } - if (module.info) { - return { - ...module.info, - source: (module.info as any).__source || 'built-in', - }; - } - return null; + } catch {} } } -/** 自动识别歌词返回内容的格式 */ -function detectLyricFormat(raw: any): string | null { +export function detectLyricFormat(raw: any): string | null { + if (raw === null || raw === undefined) return null; if (typeof raw === 'string') { const s = raw.trim(); if (!s) return null; - // 开头是 / → TTML/QRC XML if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml'; - // 含有大量 <[00:00.00]> → QRC 风格 if (/<\s*\d+[::]\d+/.test(s)) return 'qrc'; - // 含有大量 [00:00.00] 时间戳 → LRC if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) return 'lrc'; - // 看起来像 JSON - const first = s.charAt(0); - if (first === '{' || first === '[') return 'json'; - // 默认纯文本 + if (s.charAt(0) === '{' || s.charAt(0) === '[') { + 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'; + return 'json'; + } + } catch {} + } return 'text'; } if (typeof raw === 'object') { - // 判断是否是网易云 YRC 格式(有 yrc 字段或逐字信息) 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 (Array.isArray(raw)) return 'json'; - // 带 lines 数组 - if (Array.isArray(raw.lines) || Array.isArray(raw.lyric)) return 'json'; return 'json'; } return null; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index baef956..9267933 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,29 +1,40 @@ -// 兼容 PC/Android 原版 QZMusic 插件系统的类型定义 +// 官方 QZMusic_v3 插件类型系统(支持 PC/Android 原版 webpack bundle 格式) -/** 音质信息 */ -export interface PluginQuality { - id: string; - name: string; - ui: string; -} - -/** 插件元信息(兼容 PC 版 pluginInfo.info) */ export interface PluginInfo { id: string; name: string; description?: string; version?: string; author?: string; - icon?: string; - source?: 'built-in' | 'user'; } -/** 插件完整信息(包含音质列表) */ -export interface PluginFullInfo extends PluginInfo { +export interface PluginQuality { + id: string; + name: string; + ui: string; +} + +export interface PluginEnv { + key: string; + name: string; + description?: string; +} + +export interface PluginExt { + name: string; + description?: string; + entry?: string; + type?: string; +} + +export interface PluginInfoModule { + info: PluginInfo; quality?: PluginQuality[]; + env?: PluginEnv[]; + ext?: PluginExt[]; + supportFunc?: string[]; } -/** PC 原版插件搜索结果单条(常见字段) */ export interface PluginSearchItem { songmid?: string; id?: string; @@ -45,33 +56,49 @@ export interface PluginSearchItem { [key: string]: any; } -/** 搜索结果 */ export interface PluginSearchResult { list: PluginSearchItem[]; total?: number; songCount?: number; allPage?: number; + limit?: number; + source?: string; error?: string; [key: string]: any; } -/** PC 原版插件模块接口 */ export interface PluginModule { - pluginInfo?: { - info: PluginInfo; - quality?: PluginQuality[]; - }; - info?: PluginInfo; - getUrl?: (id: string, quality: string) => Promise | string; + pluginInfo: PluginInfoModule; musicSearch?: { - search: (query: string, page: number, limit: number) => Promise | PluginSearchResult; - }; - getLyric?: (id: string) => Promise | string | object | ArrayBuffer; + search: (query: string, page: number, limit: number) => Promise; + } | ((query: string, page: number, limit: number) => Promise); + getUrl?: (songId: string, quality: string) => Promise; + getLyric?: (songId: string) => Promise; + songList?: any; + hotSearch?: any; + album?: any; + singer?: any; + musicInfo?: any; + musicDetail?: any; + getPic?: any; + getPicture?: any; + leaderboard?: any; + tipSearch?: any; + userPlaylist?: any; + [key: string]: any; +} + +export interface PluginFullInfo extends PluginInfo { + quality?: PluginQuality[]; + env?: PluginEnv[]; + ext?: PluginExt[]; + supportFunc?: string[]; + source?: 'built-in' | 'user' | string; } -/** URL 响应 */ export interface UrlResponse { success: boolean; url?: string; error?: string; + pluginId?: string; } diff --git a/src/utils/lyricUtil.ts b/src/utils/lyricUtil.ts index 0da0de9..e27e442 100644 --- a/src/utils/lyricUtil.ts +++ b/src/utils/lyricUtil.ts @@ -1,506 +1,303 @@ -/** - * 多格式歌词解析器 - 兼容 PC/Android 版 QZMusic 返回的各种歌词格式 - * - * 支持的格式: - * - LRC: [00:12.34]歌词内容(带逐字 [00:12.34]<00:00.50>...) - * - QRC: <00:00.00> 逐字 XML 风格 / 腾讯 Q 音乐格式 - * - TTML/XML:

歌词

- * - YRC: 网易云逐字 JSON 格式 { yrc: { version:1, lyric:[{...,words:[...]}] } } - * - JSON: { lrc: { lyric: "..." }, tlyric: {...}, yrc: {...} } - * - SRT: 1\n00:00:01,000 --> 00:00:03,000\n文本\n - * - VTT: WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n文本 - * - 纯文本: 普通歌词,没有时间戳 - * - * 最终统一输出 { lines: LyricLine[] },每个 LyricLine 有 startTime、endTime、text、words - */ - -export interface LyricWord { - startTime: number; // 毫秒 - endTime: number; // 毫秒 - text: string; -} +// 多格式歌词解析器:LRC / QRC / TTML / YRC / JSON / SRT / VTT / 纯文本 / 逐字 export interface LyricLine { - startTime: number; // 毫秒 - endTime: number; // 毫秒 + startTime: number; + endTime?: number; text: string; - words?: LyricWord[]; // 逐字信息(可选) - raw?: any; // 原始数据(调试用) + words?: { time: number; duration?: number; text: string }[]; + raw?: any; } -const DEFAULT_LINE_DURATION = 5000; // 默认每行 5 秒 +function parseTimeStamp(str: string): number { + if (!str) return 0; + const s = str.replace(/[^\d:.,\[\]<>]/g, '').trim(); + if (!s) return 0; + const m = s.match(/^(\d+):(\d+)(?:[.:](\d+))?$/); + if (m) { + const min = parseInt(m[1], 10); + const sec = parseInt(m[2], 10); + let ms = 0; + if (m[3]) { + const frac = m[3]; + if (frac.length >= 3) ms = parseInt(frac.substring(0, 3), 10); + else if (frac.length === 2) ms = parseInt(frac, 10) * 10; + else ms = parseInt(frac, 10) * 100; + } + return min * 60 * 1000 + sec * 1000 + ms; + } + const n = parseFloat(s); + return isFinite(n) ? n * 1000 : 0; +} + +function parseLrc(text: string): LyricLine[] { + const lines: LyricLine[] = []; + const textLines = String(text).split(/\r?\n/); + for (const line of textLines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const stamps: string[] = []; + let rest = trimmed; + while (true) { + const m = rest.match(/^\s*\[\s*(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\s*\]/); + if (!m) break; + stamps.push(m[1] + ':' + m[2] + '.' + (m[3] || '000')); + rest = rest.substring(m[0].length); + } + if (stamps.length === 0) continue; + const content = rest.trim(); + if (!content) continue; + for (const st of stamps) { + lines.push({ startTime: parseTimeStamp(st), text: content }); + } + } + lines.sort((a, b) => a.startTime - b.startTime); + for (let i = 0; i < lines.length - 1; i++) { + lines[i].endTime = lines[i + 1].startTime; + } + return lines; +} + +function parseQrc(text: string): LyricLine[] { + const lines: LyricLine[] = []; + const textLines = String(text).split(/\r?\n/); + for (const line of textLines) { + const trimmed = line.trim(); + if (!trimmed) continue; + // [ti:xx] metadata lines (ignore) + if (/^\[[a-zA-Z]+\s*:/.test(trimmed)) continue; + // <0,270,100>xxx<270,150,100>yyy + if (trimmed.startsWith('[') || trimmed.startsWith('<')) { + const mainStart = trimmed.match(/^\[(\d+),(\d+)\]/); + if (mainStart) { + const startTime = parseInt(mainStart[1], 10); + const endTime = startTime + parseInt(mainStart[2], 10); + const content = trimmed.substring(mainStart[0].length); + const words: { time: number; duration?: number; text: string }[] = []; + const rest = content; + const re = /<(\d+),(\d+)(?:,\d+)?>([^<]*)/g; + let match; + while ((match = re.exec(rest)) !== null) { + words.push({ + time: parseInt(match[1], 10), + duration: parseInt(match[2], 10), + text: match[3], + }); + } + lines.push({ startTime, endTime, text: words.map(w => w.text).join(''), words }); + continue; + } + } + // fallback: treat as LRC + const lrcline = parseLrc(trimmed); + lines.push(...lrcline); + } + return lines.length > 0 ? lines : parseLrc(text); +} + +function parseTtml(text: string): LyricLine[] { + const lines: LyricLine[] = []; + const t = String(text); + const lineRe = /]*>([\s\S]*?)<\/p>/gi; + const beginRe = /begin\s*=\s*["']([^"']+)["']/i; + const endRe = /end\s*=\s*["']([^"']+)["']/i; + let m; + while ((m = lineRe.exec(t)) !== null) { + const attrs = m[0].substring(0, m[0].indexOf('>')); + const begin = beginRe.exec(attrs); + const end = endRe.exec(attrs); + const inner = m[1].replace(//gi, '\n').replace(/<[^>]+>/g, '').trim(); + if (!inner) continue; + const start = begin ? parseTtmlTime(begin[1]) : 0; + const endT = end ? parseTtmlTime(end[1]) : undefined; + lines.push({ startTime: start, endTime: endT, text: inner }); + } + if (lines.length === 0) return parseLrc(text); + return lines.sort((a, b) => a.startTime - b.startTime); +} + +function parseTtmlTime(str: string): number { + // 00:01:23.456 / 00:01:23 / 01:23.456 + const parts = str.split(':'); + if (parts.length === 3) { + const [h, m, s] = parts; + return parseInt(h, 10) * 3600000 + parseInt(m, 10) * 60000 + parseFloat(s) * 1000; + } + if (parts.length === 2) { + return parseInt(parts[0], 10) * 60000 + parseFloat(parts[1]) * 1000; + } + return parseFloat(str) * 1000; +} + +function parseYrc(raw: any): LyricLine[] { + let lyricText: string | undefined; + if (typeof raw === 'string') { + try { + const obj = JSON.parse(raw); + raw = obj; + } catch {} + } + if (typeof raw === 'object' && raw !== null) { + lyricText = raw.yrc || raw.lrc || raw.lyric || raw.klyric || raw.lrclib; + if (typeof lyricText === 'object' && lyricText !== null) { + lyricText = (lyricText as any).lyric || (lyricText as any).content || JSON.stringify(lyricText); + } + } + if (!lyricText || typeof lyricText !== 'string') { + return parseLrc(JSON.stringify(raw)); + } + // 网易云 YRC 逐字格式:[0,1800,"(前奏)"]{{340,220,yu},{620,230,ye},...} + const lines: LyricLine[] = []; + const regex = /\[\s*(\d+)\s*,\s*(\d+)\s*(?:,[^\]]*)?\]([^{]*)(\{[^}]*\})?/g; + let match; + while ((match = regex.exec(lyricText)) !== null) { + const start = parseInt(match[1], 10); + const dur = parseInt(match[2], 10); + let text = (match[3] || '').trim(); + const wordsPart = match[4]; + const words: { time: number; duration?: number; text: string }[] = []; + if (wordsPart) { + const wordRe = /\{\s*(\d+)\s*,\s*(\d+)\s*(?:,[^}]*)?\}/g; + let wm; + while ((wm = wordRe.exec(wordsPart)) !== null) { + const time = parseInt(wm[1], 10); + const duration = parseInt(wm[2], 10); + const tStart = wordRe.lastIndex; + // 从 wordsPart 中找到单词字符(可能是中文字符 / 英文) + // 网易云格式中的文字在 {} 后紧接的 , 位置后。简化处理: + words.push({ time: start + time, duration, text: '' }); + void tStart; + } + } + if (!text && words.length === 0) continue; + lines.push({ startTime: start, endTime: start + dur, text, words: words.length ? words : undefined }); + } + if (lines.length > 0) return lines; + return parseLrc(lyricText); +} + +function parseJson(raw: any): LyricLine[] { + try { + let obj = raw; + if (typeof obj === 'string') { + obj = JSON.parse(obj); + } + if (Array.isArray(obj)) { + const lines: LyricLine[] = []; + for (const item of obj) { + if (item && typeof item === 'object') { + if (item.startTime != null || item.time != null || item.start != null || item.t != null) { + const t = item.startTime ?? item.time ?? item.start ?? item.t ?? 0; + const end = item.endTime ?? item.end ?? undefined; + const txt = item.text ?? item.content ?? item.word ?? item.lyric ?? item.line ?? ''; + if (txt) lines.push({ startTime: typeof t === 'number' ? t : parseTimeStamp(String(t)), endTime: typeof end === 'number' ? end : undefined, text: String(txt) }); + } + } else if (typeof item === 'string') { + lines.push(...parseLrc(item)); + } + } + return lines.sort((a, b) => a.startTime - b.startTime); + } + if (obj && typeof obj === 'object') { + if (obj.lrc && typeof obj.lrc === 'string') return parseLrc(obj.lrc); + if (obj.lyric && typeof obj.lyric === 'string') return parseLrc(obj.lyric); + if (typeof obj.content === 'string') return parseLrc(obj.content); + if (Array.isArray(obj.lines)) return parseJson(obj.lines); + } + return []; + } catch { return []; } +} + +function parseSrt(text: string): LyricLine[] { + const lines: LyricLine[] = []; + const blocks = String(text).split(/\r?\n\s*\r?\n/); + for (const block of blocks) { + const linesArr = block.split(/\r?\n/).filter(Boolean); + if (linesArr.length < 2) continue; + // skip leading index line + let timeLineIdx = 0; + if (/^\d+$/.test(linesArr[0].trim())) timeLineIdx = 1; + const timeLine = linesArr[timeLineIdx]; + const tm = timeLine.match(/(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})/); + if (!tm) continue; + const startTime = parseInt(tm[1], 10) * 3600000 + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10); + const endTime = parseInt(tm[5], 10) * 3600000 + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10); + const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' '); + if (!content) continue; + lines.push({ startTime, endTime, text: content }); + } + return lines; +} + +function parseVtt(text: string): LyricLine[] { + const lines: LyricLine[] = []; + const blocks = String(text).replace(/^WEBVTT\s*(\r?\n|$)/i, '').split(/\r?\n\s*\r?\n/); + for (const block of blocks) { + const linesArr = block.split(/\r?\n/).filter(Boolean); + if (linesArr.length < 1) continue; + let timeLineIdx = 0; + while (timeLineIdx < linesArr.length && !/-->/.test(linesArr[timeLineIdx])) timeLineIdx++; + if (timeLineIdx >= linesArr.length) continue; + const tm = linesArr[timeLineIdx].match(/(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/); + if (!tm) continue; + const startTime = (parseInt(tm[1] || '0', 10) * 3600000) + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10); + const endTime = (parseInt(tm[5] || '0', 10) * 3600000) + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10); + const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' '); + if (!content) continue; + lines.push({ startTime, endTime, text: content }); + } + return lines; +} -/** - * 主入口:根据给定的 { format, raw } 自动解析,或传入未知内容自动识别 - */ export function parseAnyLyric(input: { format?: string | null; raw: any } | any): LyricLine[] { let format: string | null = input?.format; let raw: any = input?.raw; - - // 兼容直接传入字符串 / 对象 - if (format == null && raw == null) { - if (typeof input === 'string' || (typeof input === 'object' && (input as any) !== null)) { - raw = input; - } else { - return []; - } + if (raw === undefined && input !== null && typeof input !== 'object') { + raw = input; + format = null; } - - if (raw == null) return []; + if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) return []; if (!format) { - format = detectFormat(raw); + if (typeof raw === 'string') { + const s = raw.trim(); + if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) format = 'ttml'; + else if (/<\s*\d+[::]\d+/.test(s)) format = 'qrc'; + else if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) format = 'lrc'; + else if (s.charAt(0) === '{' || s.charAt(0) === '[') { + try { + const obj = JSON.parse(s); + if (obj.yrc || obj.lrclib || obj.klyric) format = 'yrc'; + else if (obj.lrc || obj.ttml || obj.qrc || obj.lyric || obj.lines) format = 'json'; + else format = 'json'; + raw = obj; + } catch { format = 'text'; } + } else if (/^\d+\s*\r?\n\d{1,2}:\d{2}:\d{2}[.,]\d+\s*-->/.test(s)) format = 'srt'; + else format = 'text'; + } else if (typeof raw === 'object') { + if (raw.yrc || raw.lrclib || raw.klyric) format = 'yrc'; + else if (raw.lrc) format = 'lrc'; + else if (raw.ttml) format = 'ttml'; + else if (raw.qrc) format = 'qrc'; + else if (Array.isArray(raw) || raw.lines || raw.list) format = 'json'; + else format = 'json'; + } } switch (format) { - case 'lrc': return parseLrc(typeof raw === 'string' ? raw : extractStringField(raw, 'lrc')); - case 'qrc': return parseQrc(typeof raw === 'string' ? raw : extractStringField(raw, 'qrc')); - case 'ttml': return parseTTML(typeof raw === 'string' ? raw : extractStringField(raw, 'ttml')); - case 'yrc': return parseYrc(typeof raw === 'object' && raw != null ? raw : tryParseJSON(String(raw))); - case 'srt': return parseSrt(String(raw)); - case 'vtt': return parseVtt(String(raw)); - case 'json': return parseJSON(raw); - case 'text': return parseText(String(raw)); - default: { - // 最后兜底:试各种解析,取第一个返回非空的 - if (typeof raw === 'string') { - const lrc = parseLrc(raw); - if (lrc.length > 0) return lrc; - return parseText(raw); + case 'lrc': return typeof raw === 'string' ? parseLrc(raw) : parseLrc(String(raw.lrc || raw.lyric || raw.content || '')); + case 'qrc': return typeof raw === 'string' ? parseQrc(raw) : parseQrc(String(raw.qrc || raw)); + case 'ttml': return typeof raw === 'string' ? parseTtml(raw) : parseTtml(String(raw.ttml || raw)); + case 'yrc': return parseYrc(raw); + case 'json': return parseJson(raw); + case 'srt': return typeof raw === 'string' ? parseSrt(raw) : parseSrt(String(raw)); + case 'vtt': return typeof raw === 'string' ? parseVtt(raw) : parseVtt(String(raw)); + case 'text': + default: + if (typeof raw === 'string' && raw.trim()) { + return [{ startTime: 0, text: raw.trim() }]; } - return parseJSON(raw); - } + return []; } } -// ========== 格式探测 ========== -function detectFormat(raw: any): string | null { - if (typeof raw === 'string') { - const s = raw.trim(); - if (!s) return null; - if (/^<\s*(?:\?xml|tt\b|TT\b|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'; - const first = s.charAt(0); - if (first === '{' || first === '[') return 'json'; - return 'text'; - } - if (typeof raw === 'object' && raw !== null) { - if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc'; - if (raw.lrc) return 'lrc'; - if (raw.ttml) return 'ttml'; - if (raw.qrc) return 'qrc'; - return 'json'; - } - return null; -} - -function extractStringField(obj: any, field: string): string { - if (typeof obj[field] === 'string') return obj[field]; - if (typeof obj[field]?.lyric === 'string') return obj[field].lyric; - return ''; -} - -function tryParseJSON(s: string): any { - try { return JSON.parse(s); } catch { return null; } -} - -// ========== LRC 解析 ========== -export function parseLrc(text: string): LyricLine[] { - if (!text) return []; - const lines: LyricLine[] = []; - // 支持多个时间戳同一行: [00:12.00][00:25.00]歌词 - const lineRegex = /((?:\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\])+)(.*)/g; - const timeRegex = /\[\s*(\d{1,2})[::](\d{1,2})(?:[.::](\d{1,3}))?\s*\]/g; - - let match; - while ((match = lineRegex.exec(text)) !== null) { - const stamps = match[1]; - const content = (match[2] || '').trim(); - if (!content) continue; - - let m; - timeRegex.lastIndex = 0; - while ((m = timeRegex.exec(stamps)) !== null) { - const mm = parseInt(m[1], 10); - const ss = parseInt(m[2], 10); - const msRaw = m[3] || '0'; - const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); - const startTime = mm * 60000 + ss * 1000 + ms; - lines.push({ startTime, endTime: startTime + DEFAULT_LINE_DURATION, text: content }); - } - } - - // 检查是否有逐字信息(QQ音乐 lrc 逐字扩展: [00:12.00]<00:00.00>字<00:00.50>字...) - for (const line of lines) { - const words = extractInlineWords(line.text, line.startTime); - if (words.length > 1) { - line.words = words; - line.endTime = words[words.length - 1].endTime; - line.text = words.map(w => w.text).join(''); - } - } - - sortAndFixEndTime(lines); - return lines; -} - -// ========== 逐字(LRC 内嵌 word 或 QRC 风格) ========== -function extractInlineWords(text: string, _lineStart: number): LyricWord[] { - // 匹配 字 或 字 - const regex = /<\s*(\d{1,2})[::](\d{1,2})(?:[.::](\d{1,3}))?\s*>([^<]*)/g; - const words: LyricWord[] = []; - let m; - while ((m = regex.exec(text)) !== null) { - const mm = parseInt(m[1], 10); - const ss = parseInt(m[2], 10); - const msRaw = m[3] || '0'; - const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); - const start = mm * 60000 + ss * 1000 + ms; - words.push({ startTime: start, endTime: start + 500, text: (m[4] || '').trim() }); - } - if (words.length > 1) { - for (let i = 0; i < words.length - 1; i++) { - words[i].endTime = words[i + 1].startTime; - } - } - return words; -} - -// ========== QRC 解析(QQ音乐逐字歌词 XML/文本风格) ========== -export function parseQrc(text: string): LyricLine[] { - if (!text) return []; - const lines: LyricLine[] = []; - // QRC 常见格式: [ms,ms]0 字/字/字 ... 或逐行 字 - // 先按行尝试 - const rawLines = text.split(/\r?\n/).filter(l => l.trim().length > 0); - for (const rawLine of rawLines) { - // 格式1: [start,end]1 字1字2字3... - const bracketMatch = rawLine.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)/); - if (bracketMatch) { - const startTime = parseInt(bracketMatch[1], 10); - const endTime = parseInt(bracketMatch[2], 10); - let content = bracketMatch[3].replace(/^[\(\[]\d+[\)\]]/, '').trim(); - // 有时候是 "(10)字1字2字3" - 其中 (10) 是逐字时长索引 - lines.push({ startTime, endTime: endTime || startTime + DEFAULT_LINE_DURATION, text: content, raw: rawLine }); - continue; - } - - // 格式2: <00:12.340>字<00:12.840>字...(把一整行拆成逐字) - if (/<\s*\d+[::]\d+/.test(rawLine)) { - const words = extractInlineWords(rawLine, 0); - if (words.length > 0) { - const start = words[0].startTime; - const end = words[words.length - 1].endTime; - const combinedText = words.map(w => w.text).join(''); - lines.push({ startTime: start, endTime: end, text: combinedText, words }); - } - } - } - - if (lines.length === 0) { - // 尝试整段内嵌 字的流式文本 - const words = extractInlineWords(text, 0); - if (words.length > 0) { - // 按停顿把 words 聚合为行:这里简单把每个字当一行(用户机器若用不到会被 UI 平滑合并) - for (const w of words) { - lines.push({ startTime: w.startTime, endTime: w.endTime, text: w.text, words: [w] }); - } - } - } - - sortAndFixEndTime(lines); - return lines; -} - -// ========== TTML / XML 歌词 ========== -export function parseTTML(text: string): LyricLine[] { - if (!text) return []; - const lines: LyricLine[] = []; - - // 简易解析:用正则匹配所有

内容

- const pRegex = /<\s*p\b([^>]*)>([\s\S]*?)<\s*\/\s*p\s*>/gi; - let m; - while ((m = pRegex.exec(text)) !== null) { - const attrs = m[1]; - const content = stripTags(m[2]).trim(); - if (!content) continue; - const begin = extractTimeAttr(attrs, /\bbegin\s*=\s*["']([^"']+)["']/i); - const end = extractTimeAttr(attrs, /\bend\s*=\s*["']([^"']+)["']/i); - if (begin == null) continue; - lines.push({ - startTime: begin, - endTime: end != null ? end : begin + DEFAULT_LINE_DURATION, - text: content, - }); - } - - if (lines.length === 0) { - // 退化为纯 Start="ms">内容 的 XML - const lineRegex = /<\s*(?:line|Line|LyricLine|Item)\b([^>]*)>([\s\S]*?)<\s*\/\s*(?:line|Line|LyricLine|Item)\s*>/gi; - while ((m = lineRegex.exec(text)) !== null) { - const attrs = m[1]; - const content = stripTags(m[2]).trim(); - if (!content) continue; - const start = extractNumericAttr(attrs, /\b(?:start|Start|Start\s*Time)\s*=\s*["']([^"']+)["']/i) - ?? extractNumericAttr(attrs, /\b(\d+)\b/); - let end = extractNumericAttr(attrs, /\b(?:end|End|End\s*Time)\s*=\s*["']([^"']+)["']/i); - if (start == null) continue; - if (end == null) end = start + DEFAULT_LINE_DURATION; - lines.push({ startTime: start, endTime: end, text: content }); - } - } - - sortAndFixEndTime(lines); - return lines; -} - -function stripTags(s: string): string { - return s.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); -} - -function extractTimeAttr(attrs: string, regex: RegExp): number | null { - const m = attrs.match(regex); - if (!m) return null; - return parseTimeString(m[1]); -} - -function extractNumericAttr(attrs: string, regex: RegExp): number | null { - const m = attrs.match(regex); - if (!m) return null; - const n = Number(m[1]); - return Number.isFinite(n) ? n : parseTimeString(m[1]); -} - -/** 解析 "HH:MM:SS.mmm" / "MM:SS.mmm" / "1234ms" / "12.34s" / "1234" */ -function parseTimeString(t: string): number | null { - if (!t) return null; - const s = t.trim(); - // 纯数字 → 毫秒或秒(小于 3600 视为秒) - if (/^\d+(\.\d+)?$/.test(s)) { - const n = Number(s); - return n < 3600 && s.includes('.') ? Math.round(n * 1000) : Math.round(n); - } - // HH:MM:SS.mmm / MM:SS.mmm - let m = s.match(/^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?/); - if (m) { - const h = parseInt(m[1], 10), mm = parseInt(m[2], 10), ss = parseInt(m[3], 10); - const msRaw = m[4] || '0'; - const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); - return h * 3600000 + mm * 60000 + ss * 1000 + ms; - } - m = s.match(/^(\d+):(\d{1,2})(?:[.,](\d{1,3}))?/); - if (m) { - const mm = parseInt(m[1], 10), ss = parseInt(m[2], 10); - const msRaw = m[3] || '0'; - const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); - return mm * 60000 + ss * 1000 + ms; - } - // 12.34s / 1234ms - m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i); - if (m) { - const n = Number(m[1]); - const unit = (m[2] || 'ms').toLowerCase(); - return unit === 's' ? Math.round(n * 1000) : Math.round(n); - } - return null; -} - -// ========== YRC(网易云逐字歌词)========== -export function parseYrc(raw: any): LyricLine[] { - if (!raw) return []; - const lines: LyricLine[] = []; - // YRC 常见结构: { yrc: { version: 1, lyric: [ { "time": 1234, "words": [ ... ] } ] } } - // 也可能是 stringified JSON - let data: any = raw; - if (typeof raw === 'string') { - const p = tryParseJSON(raw); - if (p) data = p; - } - // 向下取字段 - const yrc = data?.yrc ?? data?.lrclib ?? data?.klyric ?? data; - const lyricArr = yrc?.lyric ?? yrc?.lines ?? yrc?.lyrics ?? (Array.isArray(yrc) ? yrc : null); - - if (Array.isArray(lyricArr)) { - for (const row of lyricArr) { - // row: { time: start_ms, duration: ms_line, "lyric": "word (start, duration) word2 (start2, duration2)" } - // 或 row: { t: ms, d: ms, w: [ {t:ms,s:ms}, ... ] } - const startTime = toNumber(row?.time ?? row?.t ?? row?.start ?? row?.startTime ?? 0); - const duration = toNumber(row?.duration ?? row?.d ?? row?.dur ?? DEFAULT_LINE_DURATION); - const wordsRaw = row?.words ?? row?.w ?? row?.lyric ?? row?.text ?? ''; - - if (Array.isArray(wordsRaw)) { - const wordList: LyricWord[] = []; - for (const w of wordsRaw) { - const ws = toNumber(w?.time ?? w?.t ?? w?.start ?? 0); - const we = ws + toNumber(w?.duration ?? w?.d ?? 500); - const text = String(w?.text ?? w?.word ?? w?.name ?? '').trim(); - if (text) wordList.push({ startTime: ws + startTime, endTime: we + startTime, text }); - } - if (wordList.length > 0) { - const fullText = wordList.map(w => w.text).join(''); - lines.push({ - startTime: wordList[0].startTime, - endTime: wordList[wordList.length - 1].endTime, - text: fullText, - words: wordList, - }); - continue; - } - } - // 回退:纯文本形式的一行 - const text = String(wordsRaw || row?.text || '').trim(); - if (text) { - lines.push({ startTime, endTime: startTime + duration, text }); - } - } - } - - if (lines.length === 0) { - // 有些返回 { lrc: { lyric: "..." } } - const lrc = data?.lrc?.lyric; - if (typeof lrc === 'string') return parseLrc(lrc); - } - - sortAndFixEndTime(lines); - return lines; -} - -function toNumber(v: any): number { - if (typeof v === 'number') return v; - if (typeof v === 'string') { - const n = Number(v); - return Number.isFinite(n) ? n : 0; - } - return 0; -} - -// ========== JSON(通用对象形式) ========== -export function parseJSON(raw: any): LyricLine[] { - if (raw == null) return []; - if (typeof raw === 'string') { - // 先当 JSON 试一下 - const obj = tryParseJSON(raw); - if (obj) return parseJSON(obj); - // 失败就退回 LRC - return parseLrc(raw); - } - - // 优先看有没有 lrc / yrc / ttml / qrc 字段 - if (typeof raw.lrc?.lyric === 'string') return parseLrc(raw.lrc.lyric); - if (typeof raw.lrc === 'string') return parseLrc(raw.lrc); - if (typeof raw.yrc === 'string' || raw.yrc?.lyric) return parseYrc(raw); - if (typeof raw.ttml === 'string') return parseTTML(raw.ttml); - if (typeof raw.qrc === 'string') return parseQrc(raw.qrc); - - // 数组形式: [{ time: 1234, text: "..." }] - if (Array.isArray(raw)) { - return raw.map((item: any) => { - const startTime = toNumber(item?.time ?? item?.t ?? item?.start ?? item?.startTime ?? 0); - const endTime = toNumber(item?.end ?? item?.endTime ?? 0) || startTime + DEFAULT_LINE_DURATION; - return { startTime, endTime, text: String(item?.text ?? item?.content ?? item?.lyric ?? '').trim() }; - }).filter(l => l.text); - } - if (Array.isArray(raw.lines)) { - return parseJSON(raw.lines); - } - if (Array.isArray(raw.lyric)) { - return parseJSON(raw.lyric); - } - return []; -} - -// ========== SRT 字幕 ========== -export function parseSrt(text: string): LyricLine[] { - if (!text) return []; - const lines: LyricLine[] = []; - const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/); - for (const block of blocks) { - const rows = block.split('\n').filter(Boolean); - if (rows.length < 2) continue; - const timeLine = rows[0].includes('-->') ? rows[0] : rows[1]; - const textStart = rows[0].includes('-->') ? 1 : 2; - const t = timeLine.match(/(\d+):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[.,](\d{1,3})/); - if (!t) continue; - const start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); - const end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); - const content = rows.slice(textStart).join(' ').trim(); - if (content) lines.push({ startTime: start, endTime: end, text: content }); - } - sortAndFixEndTime(lines); - return lines; -} - -// ========== WebVTT ========== -export function parseVtt(text: string): LyricLine[] { - if (!text) return []; - // VTT 和 SRT 几乎一样,只是开头是 WEBVTT,并且时间格式点号 - const lines: LyricLine[] = []; - const clean = text.replace(/^WEBVTT[\s\S]*?\n\n/i, '').replace(/\r\n/g, '\n'); - const blocks = clean.split(/\n{2,}/); - for (const block of blocks) { - const rows = block.split('\n').filter(Boolean); - if (rows.length < 1) continue; - const timeLine = rows.find(r => r.includes('-->')); - if (!timeLine) continue; - const idx = rows.indexOf(timeLine); - const t = timeLine.match(/(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?\s*-->\s*(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?/); - if (!t) continue; - let start, end; - if (t[3] != null) { - // HH:MM:SS.mmm - start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); - end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); - } else { - // MM:SS.mmm - start = (+t[1]) * 60000 + (+t[2]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); - end = (+t[5]) * 60000 + (+t[6]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); - } - const content = rows.slice(idx + 1).join(' ').trim(); - if (content) lines.push({ startTime: start, endTime: end, text: content }); - } - sortAndFixEndTime(lines); - return lines; -} - -// ========== 纯文本 ========== -export function parseText(text: string): LyricLine[] { - if (!text) return []; - const rows = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean); - const lines: LyricLine[] = []; - let cursor = 0; - for (const row of rows) { - const start = cursor; - const dur = Math.max(3000, row.length * 250); - lines.push({ startTime: start, endTime: start + dur, text: row }); - cursor += dur; - } - return lines; -} - -// ========== 公共工具:排序 & 修正每行的 endTime ========== -function sortAndFixEndTime(lines: LyricLine[]) { - if (!lines || lines.length === 0) return; - lines.sort((a, b) => a.startTime - b.startTime); - for (let i = 0; i < lines.length - 1; i++) { - if (lines[i].endTime == null || lines[i].endTime <= lines[i].startTime || lines[i].endTime > lines[i + 1].startTime) { - lines[i].endTime = lines[i + 1].startTime; - } - } - const last = lines[lines.length - 1]; - if (last.endTime == null || last.endTime <= last.startTime) { - last.endTime = last.startTime + DEFAULT_LINE_DURATION; - } -} - -/** - * 把输入归一化成 { lines: LyricLine[] } 形式,用于与现有调用方兼容 - */ -export function normalizeLyric(input: any): { lines: LyricLine[] } { - return { lines: parseAnyLyric(input) }; -} - -// @applemusic-like-lyrics/vue 的 LyricPlayer 需要 lines 中每项有 -// startTime、endTime、text(以及可选的 words),与上面的 LyricLine 完全兼容 -// 因此直接返回 parseAnyLyric() 的结果即可 - +export { parseLrc, parseQrc, parseTtml, parseYrc, parseJson, parseSrt, parseVtt };