mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-06-20 23:35:06 +08:00
feat(chore): 适配插件格式并优化歌词解析
- 自动识别 TTML/KRC/YRC/QRC/LRC 歌词格式 - 适配最新QZ插件格式标准 - 插件安装后刷新列表缓存并同步搜索页状态
This commit is contained in:
@@ -22,6 +22,10 @@ process.env.VITE_PUBLIC = process.env.ELECTRON_RENDERER_URL ? path.join(process.
|
|||||||
let win: BrowserWindow | null
|
let win: BrowserWindow | null
|
||||||
let qzplayer: QzpController | null
|
let qzplayer: QzpController | null
|
||||||
|
|
||||||
|
function notifyPluginsChanged(action: 'installed' | 'updated' | 'uninstalled', pluginId?: string): void {
|
||||||
|
win?.webContents.send('plugin:changed', { action, pluginId })
|
||||||
|
}
|
||||||
|
|
||||||
// === Electron 窗口逻辑 ===
|
// === Electron 窗口逻辑 ===
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -81,17 +85,16 @@ ipcMain.handle('qzplayer-seek', (_, time) => qzplayer?.seek(time))
|
|||||||
// PluginSystem
|
// PluginSystem
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'plugin:call',
|
'plugin:call',
|
||||||
async (_evenv, pluginId: string, method: string, args: any[]) => {
|
async (_event, pluginId: string, method: string, args: any[]) => {
|
||||||
const plugin = new PluginSystem(pluginId)
|
const plugin = new PluginSystem(pluginId)
|
||||||
|
try {
|
||||||
if (typeof (plugin as any)[method] !== 'function') {
|
return await plugin.call(method, args)
|
||||||
|
} catch (err: any) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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) => {
|
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 () => {
|
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, {
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
title: '选择插件文件',
|
title: '选择插件文件',
|
||||||
filters: [{ name: 'JavaScript Plugins', extensions: ['js'] }],
|
filters: [{ name: 'Bundled JavaScript Plugin', extensions: ['js'] }],
|
||||||
properties: ['openFile']
|
properties: ['openFile']
|
||||||
})
|
})
|
||||||
|
|
||||||
if (canceled || filePaths.length === 0) {
|
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
|
// Cache IPC Handlers
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import path from 'path'
|
import path from 'node:path'
|
||||||
import fs from 'fs'
|
import fs from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
export interface UrlResponse {
|
export interface UrlResponse {
|
||||||
@@ -12,218 +11,400 @@ export interface UrlResponse {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginModule = {
|
type PluginInfo = {
|
||||||
getUrl?: (id: string, quality: string) => Promise<string> | string,
|
info?: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
version?: string | number
|
||||||
|
}
|
||||||
|
quality?: any[]
|
||||||
|
supportFunc?: string[]
|
||||||
|
env?: any[]
|
||||||
|
ext?: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginModule = Record<string, any> & {
|
||||||
|
default?: PluginModule
|
||||||
|
pluginInfo?: PluginInfo
|
||||||
|
info?: PluginInfo['info']
|
||||||
|
getUrl?: (...args: any[]) => any
|
||||||
|
getLyric?: (...args: any[]) => any
|
||||||
musicSearch?: {
|
musicSearch?: {
|
||||||
search: (query: string, page: number, limit: number) => Promise<any> | any
|
search?: (...args: any[]) => any
|
||||||
},
|
} | ((...args: any[]) => any)
|
||||||
getLyric?: (id: string) => Promise<string> | object
|
search?: (...args: any[]) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleCacheEntry = {
|
||||||
|
mtimeMs: number
|
||||||
|
module: PluginModule
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleCache = new Map<string, ModuleCacheEntry>()
|
||||||
|
|
||||||
|
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<T = any>(value: any): Promise<T> {
|
||||||
|
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 {
|
export class PluginSystem {
|
||||||
private pluginId: string
|
private readonly pluginId: string
|
||||||
private plugin: PluginModule | null = null
|
private plugin: PluginModule | null = null
|
||||||
|
|
||||||
constructor(pluginId: string) {
|
constructor(pluginId: string) {
|
||||||
this.pluginId = pluginId
|
this.pluginId = pluginId
|
||||||
this.loadPlugin()
|
this.loadPlugin()
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadPlugin() {
|
private loadPlugin(): void {
|
||||||
try {
|
try {
|
||||||
const pluginPath = path.join(
|
const pluginPath = getPluginEntry(this.pluginId)
|
||||||
app.getPath('userData'),
|
|
||||||
'plugins',
|
|
||||||
this.pluginId,
|
|
||||||
'index.js'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fs.existsSync(pluginPath)) {
|
if (!fs.existsSync(pluginPath)) {
|
||||||
throw new Error(`Plugin ${this.pluginId} not found`)
|
throw new Error(`Plugin ${this.pluginId} not found`)
|
||||||
}
|
}
|
||||||
|
this.plugin = loadPluginModule(pluginPath)
|
||||||
delete require.cache[require.resolve(pluginPath)]
|
} catch (err) {
|
||||||
|
console.error(`[PluginSystem] Failed to load ${this.pluginId}:`, err)
|
||||||
this.plugin = require(pluginPath)
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`[PluginSystem] load failed:`, e)
|
|
||||||
this.plugin = null
|
this.plugin = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRequiredPlugin(): PluginModule {
|
||||||
|
if (!this.plugin) {
|
||||||
|
throw new Error(`Plugin ${this.pluginId} is unavailable`)
|
||||||
|
}
|
||||||
|
return this.plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(method: string, args: any[] = []): Promise<any> {
|
||||||
|
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<UrlResponse> {
|
async getUrl(id: string, quality: string): Promise<UrlResponse> {
|
||||||
if (!this.plugin?.getUrl) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'getUrl not implemented'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// New behavior: plugin returns raw url string or throws
|
const plugin = this.getRequiredPlugin()
|
||||||
const url = await this.plugin.getUrl(id, quality)
|
if (typeof plugin.getUrl !== 'function') {
|
||||||
|
return { success: false, error: 'getUrl not implemented' }
|
||||||
if (typeof url !== 'string' || !url.startsWith('http')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid URL scheme'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = await unwrapPluginResult(plugin.getUrl.call(plugin, id, quality))
|
||||||
success: true,
|
const url = typeof result === 'string' ? result : result?.url
|
||||||
url
|
|
||||||
|
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
||||||
|
return { success: false, error: 'Invalid URL returned by plugin' }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: any) {
|
return { success: true, url }
|
||||||
return {
|
} catch (err: any) {
|
||||||
success: false,
|
return { success: false, error: err?.message || 'Plugin error' }
|
||||||
error: e.message || 'plugin error'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, page: number, limit: number): Promise<any> {
|
async search(query: string, page: number, limit: number): Promise<any> {
|
||||||
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 {
|
return {
|
||||||
list: [],
|
list: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
allPage: 0,
|
allPage: 0,
|
||||||
error: 'Search not implemented'
|
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: err?.message || 'Search failed',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await this.plugin.musicSearch.search(query, page, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLyric(id: string): Promise<any> {
|
async getLyric(id: string): Promise<any> {
|
||||||
console.log("getLyric not implemented");
|
|
||||||
if (!this.plugin?.getLyric) {
|
|
||||||
console.log("getLyric not implemented2");
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await this.plugin.getLyric(id)
|
const plugin = this.getRequiredPlugin()
|
||||||
console.log(result)
|
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
|
return result
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
console.log(e)
|
console.error(`[PluginSystem] Failed to get lyric from ${this.pluginId}:`, err)
|
||||||
console.error(e)
|
return null
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getAllPlugins(): any[] {
|
static getAllPlugins(): any[] {
|
||||||
try {
|
const pluginsPath = getPluginsPath()
|
||||||
const pluginsPath = path.join(app.getPath('userData'), 'plugins')
|
|
||||||
if (!fs.existsSync(pluginsPath)) return []
|
if (!fs.existsSync(pluginsPath)) return []
|
||||||
|
|
||||||
return fs.readdirSync(pluginsPath).map(dir => {
|
|
||||||
const pluginPath = path.join(pluginsPath, dir, 'index.js')
|
|
||||||
if (fs.existsSync(pluginPath)) {
|
|
||||||
try {
|
try {
|
||||||
// Clear cache to ensure fresh load
|
return fs.readdirSync(pluginsPath, { withFileTypes: true })
|
||||||
delete require.cache[require.resolve(pluginPath)]
|
.filter((entry) => entry.isDirectory())
|
||||||
const pluginModule = require(pluginPath)
|
.map((entry) => {
|
||||||
if (pluginModule.pluginInfo) {
|
const pluginPath = getPluginEntry(entry.name)
|
||||||
return {
|
if (!fs.existsSync(pluginPath)) return null
|
||||||
...pluginModule.pluginInfo.info,
|
|
||||||
quality: pluginModule.pluginInfo.quality,
|
try {
|
||||||
_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 {
|
return {
|
||||||
id: dir,
|
id: info?.id || entry.name,
|
||||||
name: dir,
|
name: info?.name || info?.id || entry.name,
|
||||||
description: 'No description',
|
description: info?.description || 'No description',
|
||||||
version: '0.0.0',
|
version: info?.version || '0.0.0',
|
||||||
_path: dir
|
quality: pluginInfo.quality || [],
|
||||||
|
supportFunc: pluginInfo.supportFunc || [],
|
||||||
|
_path: entry.name,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
console.error(`[PluginSystem] Failed to load plugin ${dir}:`, e)
|
console.error(`[PluginSystem] Failed to inspect ${entry.name}:`, err)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
return null
|
.filter((plugin) => plugin !== null)
|
||||||
}).filter(p => p !== null)
|
} catch (err) {
|
||||||
} catch (e) {
|
console.error('[PluginSystem] Failed to enumerate plugins:', err)
|
||||||
console.error('[PluginSystem] getAllPlugins failed:', e)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async installPlugin(filePath: string): Promise<{ success: boolean; message: string }> {
|
static async installPlugin(filePath: string): Promise<{
|
||||||
try {
|
success: boolean
|
||||||
// Require the file to get plugin info
|
message: string
|
||||||
// Notes: We might need to copy it to a temp location if 'require' caches by path strictness,
|
pluginId?: string
|
||||||
// but for now let's try requiring the source.
|
updated?: boolean
|
||||||
// If the user selects a file, it's likely outside our project.
|
}> {
|
||||||
// Node's require might need valid path.
|
if (path.extname(filePath).toLowerCase() !== '.js') {
|
||||||
|
return { success: false, message: 'Only bundled JavaScript plugin files are supported' }
|
||||||
|
}
|
||||||
|
|
||||||
// However, we can also just read the file content and do a regex check if we want to be safe,
|
const tempRoot = path.join(app.getPath('userData'), 'temp_plugins')
|
||||||
// but the user's plugin example is a JS object.
|
const tempDir = path.join(tempRoot, `install_${Date.now()}_${Math.random().toString(36).slice(2)}`)
|
||||||
// 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')
|
const tempFile = path.join(tempDir, 'index.js')
|
||||||
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
try {
|
||||||
fs.mkdirSync(tempDir, { recursive: true })
|
fs.mkdirSync(tempDir, { recursive: true })
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(filePath, tempFile)
|
fs.copyFileSync(filePath, tempFile)
|
||||||
|
|
||||||
// Clear cache just in case
|
const pluginModule = loadPluginModule(tempFile, true)
|
||||||
delete require.cache[require.resolve(tempFile)]
|
const pluginId = getPluginId(pluginModule)
|
||||||
const pluginModule = require(tempFile)
|
if (!pluginId) {
|
||||||
|
return {
|
||||||
let id = ''
|
success: false,
|
||||||
if (pluginModule.pluginInfo?.info?.id) {
|
message: 'Plugin must export pluginInfo.info.id or info.id',
|
||||||
id = pluginModule.pluginInfo.info.id
|
}
|
||||||
} else if (pluginModule.info?.id) {
|
|
||||||
// Legacy or direct format support
|
|
||||||
id = pluginModule.info.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
const targetDir = path.join(getPluginsPath(), pluginId)
|
||||||
// Cleanup
|
const targetFile = path.join(targetDir, 'index.js')
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
const stagedFile = path.join(targetDir, 'index.js.new')
|
||||||
console.error('[PluginSystem] No plugin ID found in file')
|
const isUpdate = fs.existsSync(targetFile)
|
||||||
return { success: false, message: '插件文件中未找到ID' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.mkdirSync(targetDir, { recursive: true })
|
||||||
|
fs.copyFileSync(tempFile, stagedFile)
|
||||||
|
clearPluginModuleCache(targetFile)
|
||||||
|
removeFileIfExists(targetFile)
|
||||||
|
fs.renameSync(stagedFile, targetFile)
|
||||||
|
|
||||||
fs.copyFileSync(filePath, path.join(targetDir, 'index.js'))
|
return {
|
||||||
|
success: true,
|
||||||
// Cleanup temp
|
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 })
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
return { success: true, message: '安装成功' }
|
// Best-effort cleanup.
|
||||||
} catch (e: any) {
|
}
|
||||||
console.error('[PluginSystem] Install failed:', e)
|
|
||||||
return { success: false, message: e.message || '安装失败' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static uninstallPlugin(id: string): boolean {
|
static uninstallPlugin(id: string): boolean {
|
||||||
|
if (!/^[a-z0-9._-]+$/i.test(id)) return false
|
||||||
|
|
||||||
|
const pluginPath = getPluginEntry(id)
|
||||||
|
const pluginDir = path.dirname(pluginPath)
|
||||||
try {
|
try {
|
||||||
const pluginPath = path.join(app.getPath('userData'), 'plugins', id)
|
clearPluginModuleCache(pluginPath)
|
||||||
if (fs.existsSync(pluginPath)) {
|
if (!fs.existsSync(pluginDir)) return false
|
||||||
fs.rmSync(pluginPath, { recursive: true, force: true })
|
fs.rmSync(pluginDir, { recursive: true, force: true })
|
||||||
return true
|
return true
|
||||||
}
|
} catch (err) {
|
||||||
return false
|
console.error(`[PluginSystem] Failed to uninstall ${id}:`, err)
|
||||||
} catch (e) {
|
|
||||||
console.error(`[PluginSystem] Failed to uninstall plugin ${id}:`, e)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getAll: () => ipcRenderer.invoke('plugin:getAll'),
|
getAll: () => ipcRenderer.invoke('plugin:getAll'),
|
||||||
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
|
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
|
||||||
install: () => ipcRenderer.invoke('plugin:install'),
|
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
|
// Cache Control
|
||||||
|
|||||||
1
src/renderer/src/types/electron.d.ts
vendored
1
src/renderer/src/types/electron.d.ts
vendored
@@ -20,6 +20,7 @@ export interface IElectronAPI {
|
|||||||
getAll: () => Promise<any[]>;
|
getAll: () => Promise<any[]>;
|
||||||
uninstall: (pluginId: string) => Promise<boolean>;
|
uninstall: (pluginId: string) => Promise<boolean>;
|
||||||
install: () => Promise<{ success: boolean; message: string }>;
|
install: () => Promise<{ success: boolean; message: string }>;
|
||||||
|
onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void;
|
||||||
};
|
};
|
||||||
// Cache Control
|
// Cache Control
|
||||||
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
||||||
|
|||||||
@@ -1,57 +1,286 @@
|
|||||||
import {LyricLine, parseLrc, parseQrc, parseTTML, parseYrc} from "@applemusic-like-lyrics/lyric";
|
import {
|
||||||
const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => {
|
type LyricLine,
|
||||||
const defaultLineDuration = 3000
|
type LyricWord,
|
||||||
const toFiniteNumber = (v: any, fallback: number) => {
|
parseLrc,
|
||||||
const n = typeof v === 'number' ? v : Number(v)
|
parseQrc,
|
||||||
return Number.isFinite(n) ? n : fallback
|
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('<tt') || lowerRaw.startsWith('<?xml')) &&
|
||||||
|
raw.endsWith('>')
|
||||||
|
) {
|
||||||
|
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[] = []
|
const cleaned: LyricLine[] = []
|
||||||
|
|
||||||
for (const rawLine of lines || []) {
|
for (const rawLine of lines || []) {
|
||||||
const rawWords = Array.isArray((rawLine as any).words) ? (rawLine as any).words : []
|
const rawWords = Array.isArray(rawLine?.words) ? rawLine.words : []
|
||||||
const fixedWords: any[] = []
|
const fixedWords: LyricWord[] = []
|
||||||
let prevEnd = -1
|
let previousEnd = -1
|
||||||
|
|
||||||
for (const rawWord of rawWords) {
|
for (const rawWord of rawWords) {
|
||||||
const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN)
|
const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN)
|
||||||
const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN)
|
const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN)
|
||||||
if (!Number.isFinite(rawStart)) continue
|
if (!Number.isFinite(rawStart)) continue
|
||||||
|
|
||||||
let startTime = Math.max(0, rawStart)
|
let startTime = Math.max(0, rawStart)
|
||||||
if (startTime < prevEnd) startTime = prevEnd
|
if (startTime < previousEnd) startTime = previousEnd
|
||||||
|
|
||||||
let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1
|
let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1
|
||||||
if (endTime <= startTime) endTime = startTime + 1
|
if (endTime <= startTime) endTime = startTime + 1
|
||||||
prevEnd = endTime
|
previousEnd = endTime
|
||||||
|
|
||||||
fixedWords.push({ ...rawWord, startTime, endTime })
|
fixedWords.push({ ...rawWord, startTime, endTime })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fixedWords.length === 0) continue
|
if (fixedWords.length === 0) continue
|
||||||
|
|
||||||
const firstWordStart = fixedWords[0].startTime
|
const firstWordStart = fixedWords[0].startTime
|
||||||
const lastWordEnd = fixedWords[fixedWords.length - 1].endTime
|
const lastWordEnd = fixedWords[fixedWords.length - 1].endTime
|
||||||
let startTime = toFiniteNumber((rawLine as any).startTime, firstWordStart)
|
const startTime = Math.max(
|
||||||
startTime = Math.max(0, startTime)
|
0,
|
||||||
let endTime = toFiniteNumber((rawLine as any).endTime, lastWordEnd)
|
toFiniteNumber(rawLine.startTime, firstWordStart),
|
||||||
if (!Number.isFinite(endTime) || endTime <= startTime) endTime = startTime + defaultLineDuration
|
)
|
||||||
|
let endTime = toFiniteNumber(rawLine.endTime, lastWordEnd)
|
||||||
|
|
||||||
|
if (!Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
|
endTime = startTime + defaultLineDuration
|
||||||
|
}
|
||||||
if (endTime < lastWordEnd) endTime = lastWordEnd
|
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
|
return cleaned
|
||||||
}
|
}
|
||||||
interface LyricData {
|
|
||||||
ttml?: string,
|
export function parseLyric(input: unknown): LyricLine[] {
|
||||||
yrc?: string,
|
const data = normalizeLyricData(input)
|
||||||
lrc?: string,
|
if (!data) return []
|
||||||
qrc?: string
|
|
||||||
}
|
try {
|
||||||
export function parseLyric(lyric: LyricData):LyricLine[] {
|
const parsed = parsePrimaryLyric(data)
|
||||||
let parsed:LyricLine[] = []
|
attachSupplementalLyric(
|
||||||
if (lyric.ttml != undefined) {
|
parsed,
|
||||||
parsed = parseTTML(lyric.ttml).lines;
|
data.translate ?? data.translatedLyric ?? data.tlyric,
|
||||||
} else if (lyric.yrc != undefined) {
|
'translatedLyric',
|
||||||
parsed = parseYrc(lyric.yrc);
|
)
|
||||||
} else if (lyric.lrc != undefined) {
|
attachSupplementalLyric(
|
||||||
parsed = parseLrc(lyric.lrc);
|
parsed,
|
||||||
} else if (lyric.qrc != undefined) {
|
data.romalrc ?? data.rlyric ?? data.romanLyric,
|
||||||
parsed = parseQrc(lyric.qrc)
|
'romanLyric',
|
||||||
|
)
|
||||||
|
return sanitizeLyricLines(parsed)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Lyric] Failed to parse lyric payload:', err)
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return sanitizeLyricLines(parsed);
|
|
||||||
}
|
}
|
||||||
@@ -7,19 +7,32 @@ export function formatDuration(ms: number): string {
|
|||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
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 {
|
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 {
|
return {
|
||||||
id: String(raw.songmid),
|
id: String(id),
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
artist: raw.singer,
|
artist: Array.isArray(artist) ? artist.join('、') : String(artist),
|
||||||
picUrl: raw.img || raw.m_img || raw.s_img,
|
picUrl,
|
||||||
url: '', // Empty initially
|
url: '', // Empty initially
|
||||||
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
duration: normalizeDuration(raw.interval ?? raw.duration ?? raw.dt),
|
||||||
source: raw.source,
|
source: raw.source,
|
||||||
albumId: raw.albumId ? String(raw.albumId) : null,
|
albumId: raw.albumId ? String(raw.albumId) : null,
|
||||||
albumName: raw.albumName,
|
albumName: raw.albumName,
|
||||||
type: 'Remote',
|
type: 'Remote',
|
||||||
quality: 'auto',
|
quality: 'auto',
|
||||||
types: raw.types // Store raw types for quality selection later
|
types: raw.types ?? raw.qualities
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const songs = ref<Song[]>([]);
|
|||||||
const plugins = ref<any[]>([]);
|
const plugins = ref<any[]>([]);
|
||||||
const activePlugin = ref<string>(''); // Plugin ID
|
const activePlugin = ref<string>(''); // Plugin ID
|
||||||
const isDropdownOpen = ref(false);
|
const isDropdownOpen = ref(false);
|
||||||
|
let removePluginChangeListener: (() => void) | undefined;
|
||||||
|
|
||||||
const activePluginName = computed(() => {
|
const activePluginName = computed(() => {
|
||||||
const p = plugins.value.find(p => p.id === activePlugin.value);
|
const p = plugins.value.find(p => p.id === activePlugin.value);
|
||||||
@@ -224,6 +225,10 @@ const loadPlugins = async () => {
|
|||||||
// Default to 'wy' if present, else first
|
// Default to 'wy' if present, else first
|
||||||
const wy = plugins.value.find(p => p.id === 'wy');
|
const wy = plugins.value.find(p => p.id === 'wy');
|
||||||
activePlugin.value = wy ? 'wy' : plugins.value[0].id;
|
activePlugin.value = wy ? 'wy' : plugins.value[0].id;
|
||||||
|
} else {
|
||||||
|
activePlugin.value = '';
|
||||||
|
songs.value = [];
|
||||||
|
total.value = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 () => {
|
const fetchData = async () => {
|
||||||
if (!query.value || !activePlugin.value) return;
|
if (!query.value || !activePlugin.value) return;
|
||||||
|
|
||||||
@@ -312,6 +325,7 @@ watch(activePlugin, (newVal, oldVal) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', closeDropdown);
|
document.addEventListener('click', closeDropdown);
|
||||||
|
removePluginChangeListener = window.electronAPI?.plugin?.onChanged?.(handlePluginsChanged);
|
||||||
await loadPlugins();
|
await loadPlugins();
|
||||||
// After plugins loaded, if we have a query, fetch data
|
// After plugins loaded, if we have a query, fetch data
|
||||||
if (query.value && activePlugin.value) {
|
if (query.value && activePlugin.value) {
|
||||||
@@ -321,6 +335,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', closeDropdown);
|
document.removeEventListener('click', closeDropdown);
|
||||||
|
removePluginChangeListener?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user