From 760881de4fbf21ca473ec95afce1ece2d9b6ce0b Mon Sep 17 00:00:00 2001 From: lqtmcstudio Date: Sun, 7 Jun 2026 00:51:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(chore):=20=E9=80=82=E9=85=8D=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=A0=BC=E5=BC=8F=E5=B9=B6=E4=BC=98=E5=8C=96=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E8=A7=A3=E6=9E=90=20-=20=E8=87=AA=E5=8A=A8=E8=AF=86?= =?UTF-8?q?=E5=88=AB=20TTML/KRC/YRC/QRC/LRC=20=E6=AD=8C=E8=AF=8D=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=20-=20=E9=80=82=E9=85=8D=E6=9C=80=E6=96=B0QZ=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=A0=BC=E5=BC=8F=E6=A0=87=E5=87=86=20-=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AE=89=E8=A3=85=E5=90=8E=E5=88=B7=E6=96=B0=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=BC=93=E5=AD=98=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=A1=B5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 31 +- src/main/pluginSystem.ts | 475 ++++++++++++++++++--------- src/preload/index.ts | 10 +- src/renderer/src/types/electron.d.ts | 3 +- src/renderer/src/utils/lyricUtil.ts | 299 +++++++++++++++-- src/renderer/src/utils/songUtils.ts | 23 +- src/renderer/src/views/Search.vue | 15 + 7 files changed, 656 insertions(+), 200 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 2488b80..fff436b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,10 @@ process.env.VITE_PUBLIC = process.env.ELECTRON_RENDERER_URL ? path.join(process. let win: BrowserWindow | null let qzplayer: QzpController | null +function notifyPluginsChanged(action: 'installed' | 'updated' | 'uninstalled', pluginId?: string): void { + win?.webContents.send('plugin:changed', { action, pluginId }) +} + // === Electron 窗口逻辑 === function createWindow() { @@ -81,17 +85,16 @@ ipcMain.handle('qzplayer-seek', (_, time) => qzplayer?.seek(time)) // PluginSystem ipcMain.handle( 'plugin:call', - async (_evenv, pluginId: string, method: string, args: any[]) => { + async (_event, pluginId: string, method: string, args: any[]) => { const plugin = new PluginSystem(pluginId) - - if (typeof (plugin as any)[method] !== 'function') { + try { + return await plugin.call(method, args) + } catch (err: any) { return { success: false, - error: `Method ${method} not found` + error: err?.message || `Method ${method} failed` } } - - return await (plugin as any)[method](...args) } ) @@ -100,22 +103,28 @@ ipcMain.handle('plugin:getAll', () => { }) ipcMain.handle('plugin:uninstall', (_, id: string) => { - return PluginSystem.uninstallPlugin(id) + const success = PluginSystem.uninstallPlugin(id) + if (success) notifyPluginsChanged('uninstalled', id) + return success }) ipcMain.handle('plugin:install', async () => { - if (!win) return false + if (!win) return { success: false, message: 'Window is unavailable' } const { canceled, filePaths } = await dialog.showOpenDialog(win, { title: '选择插件文件', - filters: [{ name: 'JavaScript Plugins', extensions: ['js'] }], + filters: [{ name: 'Bundled JavaScript Plugin', extensions: ['js'] }], properties: ['openFile'] }) if (canceled || filePaths.length === 0) { - return false + return { success: false, message: 'canceled' } } - return await PluginSystem.installPlugin(filePaths[0]) + const result = await PluginSystem.installPlugin(filePaths[0]) + if (result.success) { + notifyPluginsChanged(result.updated ? 'updated' : 'installed', result.pluginId) + } + return result }) // Cache IPC Handlers diff --git a/src/main/pluginSystem.ts b/src/main/pluginSystem.ts index fe11e87..474370a 100644 --- a/src/main/pluginSystem.ts +++ b/src/main/pluginSystem.ts @@ -1,9 +1,8 @@ import { app } from 'electron' -import path from 'path' -import fs from 'fs' +import path from 'node:path' +import fs from 'node:fs' import { createRequire } from 'node:module' - const require = createRequire(import.meta.url) export interface UrlResponse { @@ -12,218 +11,400 @@ export interface UrlResponse { error?: string } -type PluginModule = { - getUrl?: (id: string, quality: string) => Promise | string, +type PluginInfo = { + info?: { + id?: string + name?: string + description?: string + version?: string | number + } + quality?: any[] + supportFunc?: string[] + env?: any[] + ext?: any[] +} + +type PluginModule = Record & { + default?: PluginModule + pluginInfo?: PluginInfo + info?: PluginInfo['info'] + getUrl?: (...args: any[]) => any + getLyric?: (...args: any[]) => any musicSearch?: { - search: (query: string, page: number, limit: number) => Promise | any - }, - getLyric?: (id: string) => Promise | object + search?: (...args: any[]) => any + } | ((...args: any[]) => any) + search?: (...args: any[]) => any +} + +type ModuleCacheEntry = { + mtimeMs: number + module: PluginModule +} + +const moduleCache = new Map() + +function getPluginsPath(): string { + return path.join(app.getPath('userData'), 'plugins') +} + +function getPluginEntry(pluginId: string): string { + return path.join(getPluginsPath(), pluginId, 'index.js') +} + +function unwrapModule(rawModule: any): PluginModule { + if ( + rawModule?.default && + typeof rawModule.default === 'object' && + ( + rawModule.__esModule || + Object.keys(rawModule).length === 1 || + rawModule.default.pluginInfo || + rawModule.default.getUrl + ) + ) { + return rawModule.default + } + return rawModule +} + +function loadPluginModule(pluginPath: string, forceReload = false): PluginModule { + const resolvedPath = require.resolve(pluginPath) + const mtimeMs = fs.statSync(pluginPath).mtimeMs + const cached = moduleCache.get(resolvedPath) + + if (!forceReload && cached?.mtimeMs === mtimeMs) { + return cached.module + } + + delete require.cache[resolvedPath] + const pluginModule = unwrapModule(require(resolvedPath)) + if (!pluginModule || (typeof pluginModule !== 'object' && typeof pluginModule !== 'function')) { + throw new Error('Plugin entry must export an object') + } + + moduleCache.set(resolvedPath, { mtimeMs, module: pluginModule }) + return pluginModule +} + +function clearPluginModuleCache(pluginPath: string): void { + try { + const resolvedPath = require.resolve(pluginPath) + moduleCache.delete(resolvedPath) + delete require.cache[resolvedPath] + } catch { + // The module may not have been loaded yet. + } +} + +function getPluginInfo(pluginModule: PluginModule): PluginInfo { + if (pluginModule.pluginInfo?.info) return pluginModule.pluginInfo + if (pluginModule.info?.id) return { info: pluginModule.info } + return {} +} + +function getPluginId(pluginModule: PluginModule): string | null { + const id = getPluginInfo(pluginModule).info?.id + if (typeof id !== 'string' || !/^[a-z0-9._-]+$/i.test(id)) return null + return id +} + +async function unwrapPluginResult(value: any): Promise { + const resolved = await value + if ( + resolved && + typeof resolved === 'object' && + 'promise' in resolved && + resolved.promise && + typeof resolved.promise.then === 'function' + ) { + return await resolved.promise + } + return resolved as T +} + +function normalizeSearchItem(item: any, pluginId: string): any { + const id = item?.songmid ?? item?.id ?? item?.songId ?? '' + const artist = item?.singer ?? item?.artists ?? item?.artist ?? item?.artistName ?? '' + const pic = item?.img ?? item?.pic ?? item?.picUrl ?? item?.cover ?? '' + const mediumPic = item?.m_img ?? item?.mPic ?? pic + const smallPic = item?.s_img ?? item?.sPic ?? mediumPic + const types = item?.types ?? item?.qualities ?? {} + + return { + ...item, + id: String(id), + songmid: String(id), + singer: Array.isArray(artist) ? artist.join('、') : String(artist), + artists: Array.isArray(artist) ? artist.join('、') : String(artist), + img: pic, + pic, + m_img: mediumPic, + s_img: smallPic, + source: item?.source || pluginId, + types, + qualities: types, + } +} + +function normalizeSearchResult(result: any, pluginId: string): any { + const rawList = Array.isArray(result) ? result : result?.list + const list = Array.isArray(rawList) + ? rawList.filter(Boolean).map((item) => normalizeSearchItem(item, pluginId)) + : [] + + if (Array.isArray(result)) { + return { + list, + total: list.length, + allPage: 1, + limit: list.length, + source: pluginId, + } + } + + return { + ...result, + list, + total: result?.total ?? result?.songCount ?? list.length, + allPage: result?.allPage ?? 1, + source: result?.source ?? pluginId, + } } export class PluginSystem { - private pluginId: string + private readonly pluginId: string private plugin: PluginModule | null = null + constructor(pluginId: string) { this.pluginId = pluginId this.loadPlugin() } - private loadPlugin() { + private loadPlugin(): void { try { - const pluginPath = path.join( - app.getPath('userData'), - 'plugins', - this.pluginId, - 'index.js' - ) - + const pluginPath = getPluginEntry(this.pluginId) if (!fs.existsSync(pluginPath)) { throw new Error(`Plugin ${this.pluginId} not found`) } - - delete require.cache[require.resolve(pluginPath)] - - this.plugin = require(pluginPath) - - } catch (e: any) { - console.error(`[PluginSystem] load failed:`, e) + this.plugin = loadPluginModule(pluginPath) + } catch (err) { + console.error(`[PluginSystem] Failed to load ${this.pluginId}:`, err) this.plugin = null } } - async getUrl(id: string, quality: string): Promise { - if (!this.plugin?.getUrl) { - return { - success: false, - error: 'getUrl not implemented' - } + + private getRequiredPlugin(): PluginModule { + if (!this.plugin) { + throw new Error(`Plugin ${this.pluginId} is unavailable`) + } + return this.plugin + } + + async call(method: string, args: any[] = []): Promise { + if (method === 'search') { + return this.search(String(args[0] ?? ''), Number(args[1]) || 1, Number(args[2]) || 30) + } + if (method === 'getLyric') { + return this.getLyric(String(args[0] ?? '')) + } + if (method === 'getUrl') { + return this.getUrl(String(args[0] ?? ''), String(args[1] ?? '')) } + const plugin = this.getRequiredPlugin() + const target = plugin[method] + if (typeof target !== 'function') { + throw new Error(`Method ${method} not found`) + } + const result = await unwrapPluginResult(target.apply(plugin, args)) + return Buffer.isBuffer(result) ? result.toString('utf8') : result + } + + async getUrl(id: string, quality: string): Promise { try { - // New behavior: plugin returns raw url string or throws - const url = await this.plugin.getUrl(id, quality) - - if (typeof url !== 'string' || !url.startsWith('http')) { - return { - success: false, - error: 'Invalid URL scheme' - } + const plugin = this.getRequiredPlugin() + if (typeof plugin.getUrl !== 'function') { + return { success: false, error: 'getUrl not implemented' } } - return { - success: true, - url + const result = await unwrapPluginResult(plugin.getUrl.call(plugin, id, quality)) + const url = typeof result === 'string' ? result : result?.url + + if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { + return { success: false, error: 'Invalid URL returned by plugin' } } - } catch (e: any) { - return { - success: false, - error: e.message || 'plugin error' - } + return { success: true, url } + } catch (err: any) { + return { success: false, error: err?.message || 'Plugin error' } } } async search(query: string, page: number, limit: number): Promise { - if (!this.plugin?.musicSearch?.search) { + try { + const plugin = this.getRequiredPlugin() + let result: any + + if (plugin.musicSearch && typeof plugin.musicSearch === 'object' && typeof plugin.musicSearch.search === 'function') { + result = await unwrapPluginResult( + plugin.musicSearch.search.call(plugin.musicSearch, query, page, limit), + ) + } else if (typeof plugin.musicSearch === 'function') { + result = await unwrapPluginResult(plugin.musicSearch.call(plugin, query, page, limit)) + } else if (typeof plugin.search === 'function') { + result = await unwrapPluginResult(plugin.search.call(plugin, query, page, limit)) + } else { + return { + list: [], + total: 0, + allPage: 0, + error: 'Search not implemented', + } + } + + return normalizeSearchResult(result, this.pluginId) + } catch (err: any) { + console.error(`[PluginSystem] Search failed for ${this.pluginId}:`, err) return { list: [], total: 0, allPage: 0, - error: 'Search not implemented' + error: err?.message || 'Search failed', } } - return await this.plugin.musicSearch.search(query, page, limit) } async getLyric(id: string): Promise { - console.log("getLyric not implemented"); - if (!this.plugin?.getLyric) { - console.log("getLyric not implemented2"); - return - } try { - const result = await this.plugin.getLyric(id) - console.log(result) + const plugin = this.getRequiredPlugin() + if (typeof plugin.getLyric !== 'function') { + return null + } + + const result = await unwrapPluginResult(plugin.getLyric.call(plugin, id)) + if (Buffer.isBuffer(result)) return result.toString('utf8') return result - } catch (e: any) { - console.log(e) - console.error(e) - return {} + } catch (err) { + console.error(`[PluginSystem] Failed to get lyric from ${this.pluginId}:`, err) + return null } } static getAllPlugins(): any[] { + const pluginsPath = getPluginsPath() + if (!fs.existsSync(pluginsPath)) return [] + try { - const pluginsPath = path.join(app.getPath('userData'), 'plugins') - if (!fs.existsSync(pluginsPath)) return [] + return fs.readdirSync(pluginsPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const pluginPath = getPluginEntry(entry.name) + if (!fs.existsSync(pluginPath)) return null - return fs.readdirSync(pluginsPath).map(dir => { - const pluginPath = path.join(pluginsPath, dir, 'index.js') - if (fs.existsSync(pluginPath)) { try { - // Clear cache to ensure fresh load - delete require.cache[require.resolve(pluginPath)] - const pluginModule = require(pluginPath) - if (pluginModule.pluginInfo) { - return { - ...pluginModule.pluginInfo.info, - quality: pluginModule.pluginInfo.quality, - _path: dir - } - } + const pluginModule = loadPluginModule(pluginPath) + const pluginInfo = getPluginInfo(pluginModule) + const info = pluginInfo.info - // Fallback for current simple plugins if they don't have metadata return { - id: dir, - name: dir, - description: 'No description', - version: '0.0.0', - _path: dir + id: info?.id || entry.name, + name: info?.name || info?.id || entry.name, + description: info?.description || 'No description', + version: info?.version || '0.0.0', + quality: pluginInfo.quality || [], + supportFunc: pluginInfo.supportFunc || [], + _path: entry.name, } - } catch (e) { - console.error(`[PluginSystem] Failed to load plugin ${dir}:`, e) + } catch (err) { + console.error(`[PluginSystem] Failed to inspect ${entry.name}:`, err) return null } - } - return null - }).filter(p => p !== null) - } catch (e) { - console.error('[PluginSystem] getAllPlugins failed:', e) + }) + .filter((plugin) => plugin !== null) + } catch (err) { + console.error('[PluginSystem] Failed to enumerate plugins:', err) return [] } } - static async installPlugin(filePath: string): Promise<{ success: boolean; message: string }> { + static async installPlugin(filePath: string): Promise<{ + success: boolean + message: string + pluginId?: string + updated?: boolean + }> { + if (path.extname(filePath).toLowerCase() !== '.js') { + return { success: false, message: 'Only bundled JavaScript plugin files are supported' } + } + + const tempRoot = path.join(app.getPath('userData'), 'temp_plugins') + const tempDir = path.join(tempRoot, `install_${Date.now()}_${Math.random().toString(36).slice(2)}`) + const tempFile = path.join(tempDir, 'index.js') + try { - // Require the file to get plugin info - // Notes: We might need to copy it to a temp location if 'require' caches by path strictness, - // but for now let's try requiring the source. - // If the user selects a file, it's likely outside our project. - // Node's require might need valid path. - - // However, we can also just read the file content and do a regex check if we want to be safe, - // but the user's plugin example is a JS object. - // Let's copy it to a temporary location in userData to rely on 'require' - - const tempId = `temp_${Date.now()}` - const tempDir = path.join(app.getPath('userData'), 'temp_plugins', tempId) - const tempFile = path.join(tempDir, 'index.js') - - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }) - } - + fs.mkdirSync(tempDir, { recursive: true }) fs.copyFileSync(filePath, tempFile) - // Clear cache just in case - delete require.cache[require.resolve(tempFile)] - const pluginModule = require(tempFile) - - let id = '' - if (pluginModule.pluginInfo?.info?.id) { - id = pluginModule.pluginInfo.info.id - } else if (pluginModule.info?.id) { - // Legacy or direct format support - id = pluginModule.info.id + const pluginModule = loadPluginModule(tempFile, true) + const pluginId = getPluginId(pluginModule) + if (!pluginId) { + return { + success: false, + message: 'Plugin must export pluginInfo.info.id or info.id', + } } - if (!id) { - // Cleanup - fs.rmSync(tempDir, { recursive: true, force: true }) - console.error('[PluginSystem] No plugin ID found in file') - return { success: false, message: '插件文件中未找到ID' } - } + const targetDir = path.join(getPluginsPath(), pluginId) + const targetFile = path.join(targetDir, 'index.js') + const stagedFile = path.join(targetDir, 'index.js.new') + const isUpdate = fs.existsSync(targetFile) - // Install to real location - const targetDir = path.join(app.getPath('userData'), 'plugins', id) - if (fs.existsSync(targetDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }) - return { success: false, message: `插件 ${id} 已存在` } - } fs.mkdirSync(targetDir, { recursive: true }) + fs.copyFileSync(tempFile, stagedFile) + clearPluginModuleCache(targetFile) + removeFileIfExists(targetFile) + fs.renameSync(stagedFile, targetFile) - fs.copyFileSync(filePath, path.join(targetDir, 'index.js')) - - // Cleanup temp - fs.rmSync(tempDir, { recursive: true, force: true }) - - return { success: true, message: '安装成功' } - } catch (e: any) { - console.error('[PluginSystem] Install failed:', e) - return { success: false, message: e.message || '安装失败' } + return { + success: true, + message: isUpdate ? `Plugin ${pluginId} updated` : `Plugin ${pluginId} installed`, + pluginId, + updated: isUpdate, + } + } catch (err: any) { + console.error('[PluginSystem] Install failed:', err) + return { success: false, message: err?.message || 'Plugin installation failed' } + } finally { + clearPluginModuleCache(tempFile) + try { + fs.rmSync(tempDir, { recursive: true, force: true }) + } catch { + // Best-effort cleanup. + } } } static uninstallPlugin(id: string): boolean { + if (!/^[a-z0-9._-]+$/i.test(id)) return false + + const pluginPath = getPluginEntry(id) + const pluginDir = path.dirname(pluginPath) try { - const pluginPath = path.join(app.getPath('userData'), 'plugins', id) - if (fs.existsSync(pluginPath)) { - fs.rmSync(pluginPath, { recursive: true, force: true }) - return true - } - return false - } catch (e) { - console.error(`[PluginSystem] Failed to uninstall plugin ${id}:`, e) + clearPluginModuleCache(pluginPath) + if (!fs.existsSync(pluginDir)) return false + fs.rmSync(pluginDir, { recursive: true, force: true }) + return true + } catch (err) { + console.error(`[PluginSystem] Failed to uninstall ${id}:`, err) return false } } -} \ No newline at end of file +} + +function removeFileIfExists(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + } catch { + // The caller will surface a later copy/rename failure with more context. + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ed85e29..ea77ba6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getAll: () => ipcRenderer.invoke('plugin:getAll'), uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id), install: () => ipcRenderer.invoke('plugin:install'), + onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + change: { action: string; pluginId?: string }, + ) => callback(change) + ipcRenderer.on('plugin:changed', listener) + return () => ipcRenderer.removeListener('plugin:changed', listener) + }, }, // Cache Control @@ -46,4 +54,4 @@ contextBridge.exposeInMainWorld('electronAPI', { getAccentColor: () => ipcRenderer.invoke('settings:getAccentColor'), setAccentColor: (color: string) => ipcRenderer.invoke('settings:setAccentColor', color) } -}) \ No newline at end of file +}) diff --git a/src/renderer/src/types/electron.d.ts b/src/renderer/src/types/electron.d.ts index 0efc404..751f3d6 100644 --- a/src/renderer/src/types/electron.d.ts +++ b/src/renderer/src/types/electron.d.ts @@ -20,6 +20,7 @@ export interface IElectronAPI { getAll: () => Promise; uninstall: (pluginId: string) => Promise; install: () => Promise<{ success: boolean; message: string }>; + onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void; }; // Cache Control getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>; @@ -43,4 +44,4 @@ declare global { interface Window { electronAPI: IElectronAPI } -} \ No newline at end of file +} diff --git a/src/renderer/src/utils/lyricUtil.ts b/src/renderer/src/utils/lyricUtil.ts index 980e496..e17547b 100644 --- a/src/renderer/src/utils/lyricUtil.ts +++ b/src/renderer/src/utils/lyricUtil.ts @@ -1,57 +1,286 @@ -import {LyricLine, parseLrc, parseQrc, parseTTML, parseYrc} from "@applemusic-like-lyrics/lyric"; -const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => { - const defaultLineDuration = 3000 - const toFiniteNumber = (v: any, fallback: number) => { - const n = typeof v === 'number' ? v : Number(v) - return Number.isFinite(n) ? n : fallback +import { + type LyricLine, + type LyricWord, + parseLrc, + parseQrc, + parseTTML, + parseYrc, +} from '@applemusic-like-lyrics/lyric' + +export type LyricFormat = 'ttml' | 'krc' | 'yrc' | 'qrc' | 'lrc' + +type LyricData = { + ttml?: string + krc?: string + yrc?: string + qrc?: string + lrc?: string + lyric?: string + translate?: string + translatedLyric?: string + tlyric?: string + romalrc?: string + rlyric?: string + romanLyric?: string + [key: string]: unknown +} + +const defaultLineDuration = 3000 + +export function detectLyricFormat(rawLyric: string): LyricFormat | null { + if (!rawLyric?.trim()) return null + + const raw = rawLyric.trim() + const lowerRaw = raw.toLowerCase() + + if ( + (lowerRaw.startsWith('') + ) { + return 'ttml' } + + if (/\[\d+,\d+][^<]*<\d+,\d+,\d+>/.test(raw)) { + return 'krc' + } + + if (/\[\d+,\d+][^(]*\(\d+,\d+,\d+\)/.test(raw)) { + return 'yrc' + } + + if (/\[\d+,\d+][^(]*\(\d+,\d+\)/.test(raw)) { + return 'qrc' + } + + if (/\[\d{2,}:\d{2}(?:\.\d{1,3})?]/.test(raw)) { + return 'lrc' + } + + return null +} + +function createKrcLine( + lineStart: number, + lineDuration: number, + words: LyricWord[], +): LyricLine { + return { + startTime: lineStart, + endTime: lineStart + lineDuration, + words, + translatedLyric: '', + romanLyric: '', + isBG: false, + isDuet: false, + } +} + +function parseKrc(krc: string): LyricLine[] { + const linePattern = /^\[(\d+),(\d+)](.*)$/ + const timedWordPattern = /<(\d+),(\d+),\d+>([^<]*)/g + + return krc + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const lineMatch = line.match(linePattern) + if (!lineMatch) return null + + const lineStart = Number(lineMatch[1]) + const lineDuration = Number(lineMatch[2]) + const content = lineMatch[3] + const words: LyricWord[] = [] + + for (const match of content.matchAll(timedWordPattern)) { + const offset = Number(match[1]) + const duration = Number(match[2]) + const text = match[3] + if (!text) continue + + const startTime = lineStart + offset + words.push({ + word: text, + startTime, + endTime: startTime + Math.max(1, duration), + }) + } + + if (words.length === 0) { + const text = content.replace(/<\d+,\d+,\d+>/g, '').trim() + if (!text) return null + words.push({ + word: text, + startTime: lineStart, + endTime: lineStart + Math.max(1, lineDuration), + }) + } + + return createKrcLine(lineStart, lineDuration, words) + }) + .filter((line): line is LyricLine => line !== null) +} + +function normalizeLyricData(input: unknown): LyricData | null { + if (typeof input === 'string') { + const raw = input.trim() + if (!raw) return null + + if (raw.startsWith('{')) { + try { + return normalizeLyricData(JSON.parse(raw)) + } catch (err) { + console.warn('[Lyric] Invalid JSON lyric payload:', err) + return null + } + } + + const format = detectLyricFormat(raw) + return format ? { [format]: raw } : null + } + + if (!input || typeof input !== 'object') return null + + const data = input as LyricData + if ( + typeof data.ttml === 'string' || + typeof data.krc === 'string' || + typeof data.yrc === 'string' || + typeof data.qrc === 'string' || + typeof data.lrc === 'string' + ) { + return data + } + + if (typeof data.lyric === 'string') { + const format = detectLyricFormat(data.lyric) + if (format) { + return { + ...data, + [format]: data.lyric, + } + } + } + + return data +} + +function parsePrimaryLyric(data: LyricData): LyricLine[] { + if (typeof data.ttml === 'string') return parseTTML(data.ttml).lines + if (typeof data.krc === 'string') return parseKrc(data.krc) + if (typeof data.yrc === 'string') return parseYrc(data.yrc) + if (typeof data.qrc === 'string') return parseQrc(data.qrc) + if (typeof data.lrc === 'string') return parseLrc(data.lrc) + return [] +} + +function lineText(line: LyricLine): string { + return line.words.map((word) => word.word).join('').trim() +} + +function attachSupplementalLyric( + lines: LyricLine[], + rawLyric: unknown, + field: 'translatedLyric' | 'romanLyric', +): void { + if (typeof rawLyric !== 'string' || !rawLyric.trim() || lines.length === 0) return + + let supplemental: LyricLine[] + try { + supplemental = parseLrc(rawLyric) + } catch (err) { + console.warn(`[Lyric] Failed to parse ${field}:`, err) + return + } + + for (const extraLine of supplemental) { + const text = lineText(extraLine) + if (!text) continue + + let target = lines.find((line) => line.startTime === extraLine.startTime) + if (!target) { + target = lines.find( + (line) => Math.abs(line.startTime - extraLine.startTime) <= 250, + ) + } + if (target) target[field] = text + } +} + +const toFiniteNumber = (value: unknown, fallback: number): number => { + const number = typeof value === 'number' ? value : Number(value) + return Number.isFinite(number) ? number : fallback +} + +const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => { const cleaned: LyricLine[] = [] + for (const rawLine of lines || []) { - const rawWords = Array.isArray((rawLine as any).words) ? (rawLine as any).words : [] - const fixedWords: any[] = [] - let prevEnd = -1 + const rawWords = Array.isArray(rawLine?.words) ? rawLine.words : [] + const fixedWords: LyricWord[] = [] + let previousEnd = -1 + for (const rawWord of rawWords) { const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN) const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN) if (!Number.isFinite(rawStart)) continue + let startTime = Math.max(0, rawStart) - if (startTime < prevEnd) startTime = prevEnd + if (startTime < previousEnd) startTime = previousEnd + let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1 if (endTime <= startTime) endTime = startTime + 1 - prevEnd = endTime + previousEnd = endTime + fixedWords.push({ ...rawWord, startTime, endTime }) } + if (fixedWords.length === 0) continue const firstWordStart = fixedWords[0].startTime const lastWordEnd = fixedWords[fixedWords.length - 1].endTime - let startTime = toFiniteNumber((rawLine as any).startTime, firstWordStart) - startTime = Math.max(0, startTime) - let endTime = toFiniteNumber((rawLine as any).endTime, lastWordEnd) - if (!Number.isFinite(endTime) || endTime <= startTime) endTime = startTime + defaultLineDuration + const startTime = Math.max( + 0, + toFiniteNumber(rawLine.startTime, firstWordStart), + ) + let endTime = toFiniteNumber(rawLine.endTime, lastWordEnd) + + if (!Number.isFinite(endTime) || endTime <= startTime) { + endTime = startTime + defaultLineDuration + } if (endTime < lastWordEnd) endTime = lastWordEnd - cleaned.push({ ...(rawLine as any), startTime, endTime, words: fixedWords }) + cleaned.push({ + ...rawLine, + startTime, + endTime, + words: fixedWords, + }) } - cleaned.sort((a: any, b: any) => (a?.startTime ?? 0) - (b?.startTime ?? 0)) + + cleaned.sort((a, b) => a.startTime - b.startTime) return cleaned } -interface LyricData { - ttml?: string, - yrc?: string, - lrc?: string, - qrc?: string -} -export function parseLyric(lyric: LyricData):LyricLine[] { - let parsed:LyricLine[] = [] - if (lyric.ttml != undefined) { - parsed = parseTTML(lyric.ttml).lines; - } else if (lyric.yrc != undefined) { - parsed = parseYrc(lyric.yrc); - } else if (lyric.lrc != undefined) { - parsed = parseLrc(lyric.lrc); - } else if (lyric.qrc != undefined) { - parsed = parseQrc(lyric.qrc) + +export function parseLyric(input: unknown): LyricLine[] { + const data = normalizeLyricData(input) + if (!data) return [] + + try { + const parsed = parsePrimaryLyric(data) + attachSupplementalLyric( + parsed, + data.translate ?? data.translatedLyric ?? data.tlyric, + 'translatedLyric', + ) + attachSupplementalLyric( + parsed, + data.romalrc ?? data.rlyric ?? data.romanLyric, + 'romanLyric', + ) + return sanitizeLyricLines(parsed) + } catch (err) { + console.error('[Lyric] Failed to parse lyric payload:', err) + return [] } - return sanitizeLyricLines(parsed); -} \ No newline at end of file +} diff --git a/src/renderer/src/utils/songUtils.ts b/src/renderer/src/utils/songUtils.ts index d6f777c..7d568be 100644 --- a/src/renderer/src/utils/songUtils.ts +++ b/src/renderer/src/utils/songUtils.ts @@ -7,19 +7,32 @@ export function formatDuration(ms: number): string { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } +function normalizeDuration(value: unknown): string { + if (typeof value === 'string' && /^\d{1,3}:\d{2}$/.test(value)) { + return value; + } + + const milliseconds = Number(value); + return Number.isFinite(milliseconds) ? formatDuration(milliseconds) : '00:00'; +} + export function transformSearchSong(raw: any): Song { + const id = raw.songmid ?? raw.id ?? raw.songId ?? ''; + const artist = raw.singer ?? raw.artists ?? raw.artist ?? raw.artistName ?? ''; + const picUrl = raw.img ?? raw.pic ?? raw.picUrl ?? raw.cover ?? raw.m_img ?? raw.mPic ?? ''; + return { - id: String(raw.songmid), + id: String(id), name: raw.name, - artist: raw.singer, - picUrl: raw.img || raw.m_img || raw.s_img, + artist: Array.isArray(artist) ? artist.join('、') : String(artist), + picUrl, url: '', // Empty initially - duration: formatDuration(raw.interval ? Number(raw.interval) : 0), + duration: normalizeDuration(raw.interval ?? raw.duration ?? raw.dt), source: raw.source, albumId: raw.albumId ? String(raw.albumId) : null, albumName: raw.albumName, type: 'Remote', quality: 'auto', - types: raw.types // Store raw types for quality selection later + types: raw.types ?? raw.qualities }; } diff --git a/src/renderer/src/views/Search.vue b/src/renderer/src/views/Search.vue index 50a7842..4a6bd8e 100644 --- a/src/renderer/src/views/Search.vue +++ b/src/renderer/src/views/Search.vue @@ -151,6 +151,7 @@ const songs = ref([]); const plugins = ref([]); const activePlugin = ref(''); // Plugin ID const isDropdownOpen = ref(false); +let removePluginChangeListener: (() => void) | undefined; const activePluginName = computed(() => { const p = plugins.value.find(p => p.id === activePlugin.value); @@ -224,6 +225,10 @@ const loadPlugins = async () => { // Default to 'wy' if present, else first const wy = plugins.value.find(p => p.id === 'wy'); activePlugin.value = wy ? 'wy' : plugins.value[0].id; + } else { + activePlugin.value = ''; + songs.value = []; + total.value = 0; } } } catch (e) { @@ -231,6 +236,14 @@ const loadPlugins = async () => { } }; +const handlePluginsChanged = async () => { + const previousPlugin = activePlugin.value; + await loadPlugins(); + if (query.value && activePlugin.value && activePlugin.value === previousPlugin) { + await fetchData(); + } +}; + const fetchData = async () => { if (!query.value || !activePlugin.value) return; @@ -312,6 +325,7 @@ watch(activePlugin, (newVal, oldVal) => { onMounted(async () => { document.addEventListener('click', closeDropdown); + removePluginChangeListener = window.electronAPI?.plugin?.onChanged?.(handlePluginsChanged); await loadPlugins(); // After plugins loaded, if we have a query, fetch data if (query.value && activePlugin.value) { @@ -321,6 +335,7 @@ onMounted(async () => { onBeforeUnmount(() => { document.removeEventListener('click', closeDropdown); + removePluginChangeListener?.(); });