feat(chore): 适配插件格式并优化歌词解析

- 自动识别 TTML/KRC/YRC/QRC/LRC 歌词格式
- 适配最新QZ插件格式标准
- 插件安装后刷新列表缓存并同步搜索页状态
This commit is contained in:
lqtmcstudio
2026-06-07 00:51:46 +08:00
parent 72f4510dc8
commit 760881de4f
7 changed files with 656 additions and 200 deletions

View File

@@ -20,6 +20,7 @@ export interface IElectronAPI {
getAll: () => Promise<any[]>;
uninstall: (pluginId: string) => Promise<boolean>;
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
}
}
}

View File

@@ -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('<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[] = []
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);
}
}

View File

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

View File

@@ -151,6 +151,7 @@ const songs = ref<Song[]>([]);
const plugins = ref<any[]>([]);
const activePlugin = ref<string>(''); // 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?.();
});
</script>