diff --git a/AGENTS.md b/AGENTS.md index f274744e0..66a3cb806 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - **包管理器**: **严格使用 `pnpm`** (严禁使用 npm/yarn) - **开发命令**: `pnpm dev` (预览代码) - **代码质量**: 每次任务结束前,必须自动运行 `pnpm lint` 并修复所有问题,确保 0 错误、0 警告后方可交付。 +- **提交之前**: 必须运行 `pnpm build` 和 `pnpm format` 并修复所有问题,确保 0 错误、0 警告后方可提交。 ## 代码风格与规范 diff --git a/src/core/resource/DownloadManager.ts b/src/core/resource/DownloadManager.ts index a958bced1..ddb546922 100644 --- a/src/core/resource/DownloadManager.ts +++ b/src/core/resource/DownloadManager.ts @@ -466,6 +466,19 @@ class DownloadManager { this.processQueue(); } + public removeDownload(id: number | string) { + const dataStore = useDataStore(); + // 如果正在下载,尝试取消(目前仅移除任务) + if (this.activeDownloads.has(id)) { + // TODO: 实现取消正在进行的下载任务 + // 暂时只能从 UI 移除 + } + // 从队列中移除 + this.queue = this.queue.filter((task) => task.id !== id); + // 从 store 移除 + dataStore.removeDownloadingSong(id); + } + public retryDownload(id: number | string) { const dataStore = useDataStore(); const task = dataStore.downloadingSongs.find((s) => s.song.id === id); diff --git a/src/utils/lyric/lyricParser.ts b/src/utils/lyric/lyricParser.ts index 2deed0356..7105848e8 100644 --- a/src/utils/lyric/lyricParser.ts +++ b/src/utils/lyric/lyricParser.ts @@ -1,3 +1,4 @@ +import { cloneDeep } from "lodash-es"; import type { LyricLine } from "@applemusic-like-lyrics/lyric"; import { parseLrc } from "../parseLrc"; @@ -20,19 +21,26 @@ type LyricWord = { word: string; startTime: number; endTime: number; romanWord: const META_TAG_REGEX = /^\[[a-z]+:/i; const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{1,})\]/g; const ENHANCED_TIME_TAG_REGEX = /<(\d{2}):(\d{2})\.(\d{1,})>/; -const WORD_BY_WORD_REGEX = /\[(\d{2}):(\d{2})\.(\d{1,})\]([^[\]]*)/g; -const ENHANCED_WORD_REGEX = /<(\d{2}):(\d{2})\.(\d{1,})>([^<]*)/g; +// 移除全局带状态的正则,改为在函数内使用 matchAll 或重新构建 const LINE_TIME_REGEX = /^\[(\d{2}):(\d{2})\.(\d{1,})\]/; +// QRC 解析相关正则 - 提前编译 +const QRC_LINE_PATTERN = /^\[(\d+),(\d+)\](.*)$/; +const QRC_WORD_PATTERN = /([^(]*)\((\d+),(\d+)\)/g; + +const DEFAULT_WORD_DURATION = 1000; +const ALIGN_TOLERANCE_MS = 300; + /** * 解析时间戳为毫秒 + * 使用字符串补齐处理,避免浮点数计算误差 */ const parseTimeToMs = (min: string, sec: string, ms: string): number => { const minutes = parseInt(min, 10); const seconds = parseInt(sec, 10); - // treat ms part as fraction of second - const fracStr = "0." + ms; - const milliseconds = parseFloat(fracStr) * 1000; + // 补齐到 3 位 (例如 "5" -> "500", "05" -> "050", "1234" -> "123") + const msNormalized = ms.padEnd(3, "0").slice(0, 3); + const milliseconds = parseInt(msNormalized, 10); return minutes * 60 * 1000 + seconds * 1000 + milliseconds; }; @@ -59,22 +67,6 @@ const createLine = (words: LyricWord[], startTime: number, endTime: number = 0): isDuet: false, }); -/** - * 修正歌词行的结束时间 - * 每行最后一个字的结束时间 = 下一行的开始时间 - */ -const fixLineEndTimes = (lines: LyricLine[]): void => { - const len = lines.length; - for (let i = 0; i < len; i++) { - const line = lines[i]; - const lastWord = line.words[line.words.length - 1]; - const nextLineStart = lines[i + 1]?.startTime; - // 如果有下一行,使用下一行的开始时间;否则使用最后一个字开始时间 + 1s - lastWord.endTime = nextLineStart ?? lastWord.startTime + 1000; - line.endTime = lastWord.endTime; - } -}; - /** * 检测 LRC 格式类型 */ @@ -98,9 +90,12 @@ export const detectLrcFormat = (content: string): LrcFormat => { /** * 解析逐字 LRC 格式 + * 优化:在解析过程中直接计算 endTime,避免二次遍历 */ export const parseWordByWordLrc = (content: string): LyricLine[] => { const result: LyricLine[] = []; + let prevLine: LyricLine | null = null; + const WORD_BY_WORD_PATTERN = /\[(\d{2}):(\d{2})\.(\d{1,})\]([^[\\]]*)/g; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); @@ -108,35 +103,56 @@ export const parseWordByWordLrc = (content: string): LyricLine[] => { const words: LyricWord[] = []; let lineStartTime = Infinity; - let match: RegExpExecArray | null; + + let prevWord: LyricWord | null = null; - // 重置正则状态 - WORD_BY_WORD_REGEX.lastIndex = 0; + const matches = line.matchAll(WORD_BY_WORD_PATTERN); - while ((match = WORD_BY_WORD_REGEX.exec(line)) !== null) { + for (const match of matches) { const startTime = parseTimeToMs(match[1], match[2], match[3]); - const word = match[4]; + const wordText = match[4]; - if (!word && words.length === 0) continue; + if (!wordText && words.length === 0) continue; lineStartTime = Math.min(lineStartTime, startTime); - // 上一个字的结束时间 = 当前字的开始时间 - if (words.length > 0) { - words[words.length - 1].endTime = startTime; + // 设置上一个字的结束时间 + if (prevWord) { + prevWord.endTime = startTime; } - if (word) { - words.push(createWord(word, startTime)); + if (wordText) { + const newWord = createWord(wordText, startTime); + words.push(newWord); + prevWord = newWord; } } + // 处理行内最后一个字 + if (prevWord) { + prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION; + } + if (words.length > 0) { - result.push(createLine(words, lineStartTime === Infinity ? 0 : lineStartTime)); + const lineObj = createLine(words, lineStartTime === Infinity ? 0 : lineStartTime); + // 设置行结束时间为最后一个字的结束时间 + lineObj.endTime = words[words.length - 1].endTime; + + // 修正上一行的结束时间 (Single Pass) + if (prevLine) { + const prevLastWord = prevLine.words[prevLine.words.length - 1]; + // 只有当当前行开始时间晚于上一行最后一个字的开始时间时,才进行截断 + if (lineObj.startTime > prevLastWord.startTime) { + prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime); + prevLine.endTime = prevLastWord.endTime; + } + } + + result.push(lineObj); + prevLine = lineObj; } } - - fixLineEndTimes(result); + return result; }; @@ -145,6 +161,8 @@ export const parseWordByWordLrc = (content: string): LyricLine[] => { */ export const parseEnhancedLrc = (content: string): LyricLine[] => { const result: LyricLine[] = []; + let prevLine: LyricLine | null = null; + const ENHANCED_WORD_PATTERN = /<(\d{2}):(\d{2})\.(\d{1,})>([^<]*)/g; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); @@ -157,38 +175,58 @@ export const parseEnhancedLrc = (content: string): LyricLine[] => { const contentAfterTime = line.slice(lineTimeMatch[0].length); const words: LyricWord[] = []; - + // 检查是否有增强型标记 if (ENHANCED_TIME_TAG_REGEX.test(contentAfterTime)) { - let match: RegExpExecArray | null; - ENHANCED_WORD_REGEX.lastIndex = 0; + let prevWord: LyricWord | null = null; + + const matches = contentAfterTime.matchAll(ENHANCED_WORD_PATTERN); - while ((match = ENHANCED_WORD_REGEX.exec(contentAfterTime)) !== null) { + for (const match of matches) { const startTime = parseTimeToMs(match[1], match[2], match[3]); - const word = match[4]; + const wordText = match[4]; - if (words.length > 0) { - words[words.length - 1].endTime = startTime; + if (prevWord) { + prevWord.endTime = startTime; } - if (word) { - words.push(createWord(word, startTime)); + if (wordText) { + const newWord = createWord(wordText, startTime); + words.push(newWord); + prevWord = newWord; } } + + if (prevWord) { + prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION; // 默认兜底 + } + } else { // 无增强型标记,作为整行处理 const text = contentAfterTime.trim(); if (text) { - words.push(createWord(text, lineStartTime)); + words.push(createWord(text, lineStartTime, lineStartTime + DEFAULT_WORD_DURATION)); // 默认持续1s } } if (words.length > 0) { - result.push(createLine(words, lineStartTime)); + const lineObj = createLine(words, lineStartTime); + lineObj.endTime = words[words.length - 1].endTime; + + // 修正上一行的结束时间 (Single Pass) + if (prevLine) { + const prevLastWord = prevLine.words[prevLine.words.length - 1]; + if (lineObj.startTime > prevLastWord.startTime) { + prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime); + prevLine.endTime = prevLastWord.endTime; + } + } + + result.push(lineObj); + prevLine = lineObj; } } - fixLineEndTimes(result); return result; }; @@ -222,106 +260,123 @@ export const isWordLevelFormat = (format: LrcFormat): boolean => /** * 歌词内容对齐 - * @param lyrics 歌词数据 + * 使用双指针算法实现 O(N) 复杂度 + * @param lyrics 歌词数据 (Readonly) * @param otherLyrics 其他歌词数据 * @param key 对齐类型 - * @returns 对齐后的歌词数据 + * @returns 对齐后的歌词数据 (新副本) */ export const alignLyrics = ( - lyrics: LyricLine[], - otherLyrics: LyricLine[], + lyrics: Readonly, + otherLyrics: Readonly, key: "translatedLyric" | "romanLyric", ): LyricLine[] => { - const lyricsData = lyrics; - if (lyricsData.length && otherLyrics.length) { - lyricsData.forEach((v: LyricLine) => { - otherLyrics.forEach((x: LyricLine) => { - if (v.startTime === x.startTime || Math.abs(v.startTime - x.startTime) < 300) { - v[key] = x.words.map((word) => word.word).join(""); - } - }); - }); + if (!lyrics.length || !otherLyrics.length) return cloneDeep(lyrics) as LyricLine[]; + + const result = cloneDeep(lyrics) as LyricLine[]; + + let i = 0; + let j = 0; + + while (i < result.length && j < otherLyrics.length) { + const line = result[i]; + const other = otherLyrics[j]; + const diff = line.startTime - other.startTime; + + if (Math.abs(diff) <= ALIGN_TOLERANCE_MS) { + // 匹配成功 + line[key] = other.words.map((word) => word.word).join(""); + i++; + j++; + } else if (diff < 0) { + // 当前歌词时间较早,移动当前指针 + i++; + } else { + // 目标歌词时间较早,移动目标指针 + j++; + } } - return lyricsData; + return result; }; /** - * 解析 QQ 音乐 QRC 格式歌词 - * @param qrcContent QRC 原始内容 - * @param trans 翻译歌词 - * @param roma 罗马音歌词(QRC 格式) - * @returns LyricLine 数组 + * 解析 QRC 内容为行数据 */ -export const parseQRCLyric = (qrcContent: string, trans?: string, roma?: string): LyricLine[] => { - // 行匹配: [开始时间,持续时间]内容 - const linePattern = /^\[(\d+),(\d+)\](.*)$/; - // 逐字匹配: 文字(开始时间,持续时间) - const wordPattern = /([^(]*)\((\d+),(\d+)\)/g; - - /** - * 解析 QRC 内容为行数据 - */ - const parseQRCContent = ( - rawContent: string, - ): Array<{ +const parseQRCContent = ( + rawContent: string, +): Array<{ + startTime: number; + endTime: number; + words: Array<{ word: string; startTime: number; endTime: number }>; +}> => { + // 提取 XML 属性 LyricContent + // 使用正则提取,兼容空格 + const lyricContentMatch = /LyricContent\s*=\s*"([^"]*)"/.exec(rawContent); + let content = rawContent; + + if (lyricContentMatch && lyricContentMatch[1]) { + content = lyricContentMatch[1]; + } + + const result: Array<{ startTime: number; endTime: number; words: Array<{ word: string; startTime: number; endTime: number }>; - }> => { - // 从 XML 中提取歌词内容 - const contentMatch = /]*LyricContent="([^"]*)"[^>]*\/>/.exec(rawContent); - const content = contentMatch ? contentMatch[1] : rawContent; + }> = []; - const result: Array<{ - startTime: number; - endTime: number; - words: Array<{ word: string; startTime: number; endTime: number }>; - }> = []; - - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - - // 跳过元数据标签 [ti:xxx] [ar:xxx] 等 - if (/^\\[[a-z]+:/i.test(line)) continue; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; - const lineMatch = linePattern.exec(line); - if (!lineMatch) continue; + // 跳过元数据标签 [ti:xxx] [ar:xxx] 等 + if (/^\\[[a-z]+:/i.test(line)) continue; - const lineStart = parseInt(lineMatch[1], 10); - const lineDuration = parseInt(lineMatch[2], 10); - const lineContent = lineMatch[3]; + const lineMatch = QRC_LINE_PATTERN.exec(line); + if (!lineMatch) continue; - // 解析逐字 - const words: Array<{ word: string; startTime: number; endTime: number }> = []; - let wordMatch: RegExpExecArray | null; - const wordRegex = new RegExp(wordPattern.source, "g"); + const lineStart = parseInt(lineMatch[1], 10); + const lineDuration = parseInt(lineMatch[2], 10); + const lineContent = lineMatch[3]; - while ((wordMatch = wordRegex.exec(lineContent)) !== null) { - const wordText = wordMatch[1]; - const wordStart = parseInt(wordMatch[2], 10); - const wordDuration = parseInt(wordMatch[3], 10); + // 解析逐字 + const words: Array<{ word: string; startTime: number; endTime: number }> = []; + + const matches = lineContent.matchAll(QRC_WORD_PATTERN); - if (wordText) { - words.push({ - word: wordText, - startTime: wordStart, - endTime: wordStart + wordDuration, - }); - } - } + for (const match of matches) { + const wordText = match[1]; + const wordStart = parseInt(match[2], 10); + const wordDuration = parseInt(match[3], 10); - if (words.length > 0) { - result.push({ - startTime: lineStart, - endTime: lineStart + lineDuration, - words, + if (wordText) { + words.push({ + word: wordText, + startTime: wordStart, + endTime: wordStart + wordDuration, }); } } - return result; - }; + if (words.length > 0) { + result.push({ + startTime: lineStart, + endTime: lineStart + lineDuration, + words, + }); + } + } + return result; +}; + +/** + * 解析 QQ 音乐 QRC 格式歌词 + * @param qrcContent QRC 原始内容 + * @param trans 翻译歌词 + * @param roma 罗马音歌词(QRC 格式) + * @returns LyricLine 数组 + */ +export const parseQRCLyric = (qrcContent: string, trans?: string, roma?: string): LyricLine[] => { + // 解析主歌词 const qrcLines = parseQRCContent(qrcContent); let result: LyricLine[] = qrcLines.map((qrcLine) => { @@ -381,6 +436,59 @@ export const parseQRCLyric = (qrcContent: string, trans?: string, roma?: string) return result; }; +// XML Builder Helper Class +class XmlNode { + name: string; + attributes: Record; + children: (XmlNode | string)[]; + + constructor(name: string, attributes: Record = {}) { + this.name = name; + this.attributes = attributes; + this.children = []; + } + + addChild(child: XmlNode | string) { + this.children.push(child); + return this; + } + + private escape(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + toString(indent = 0): string { + const spaces = " ".repeat(indent); + const attrs = Object.entries(this.attributes) + .map(([key, val]) => `${key}="${this.escape(String(val))}"`) + .join(" "); + + const attrStr = attrs ? " " + attrs : ""; + + if (this.children.length === 0) { + return `${spaces}<${this.name}${attrStr} />`; + } + + const isAllText = this.children.every((c) => typeof c === "string"); + + if (isAllText) { + const textContent = this.children.map(c => this.escape(c as string)).join(""); + return `${spaces}<${this.name}${attrStr}>${textContent}`; + } + + const childrenStr = this.children + .map((c) => (typeof c === "string" ? this.escape(c) : c.toString(indent + 2))) + .join("\n"); + + return `${spaces}<${this.name}${attrStr}>\n${childrenStr}\n${spaces}`; + } +} + /** * 将 LyricLine 数组转换为 TTML 格式 * @param lines LyricLine 数组 @@ -395,59 +503,49 @@ export const lyricLinesToTTML = (lines: LyricLine[]): string => { return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toFixed(3).padStart(6, "0")}`; }; - const escapeXml = (text: string): string => { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }; + const root = new XmlNode("tt", { + "xmlns": "http://www.w3.org/ns/ttml", + "xmlns:ttm": "http://www.w3.org/ns/ttml#metadata", + "xmlns:amll": "http://www.example.com/ns/amll" + }); + + const head = new XmlNode("head"); + const metadata = new XmlNode("metadata"); + metadata.addChild(new XmlNode("ttm:title").addChild("Lyrics")); + head.addChild(metadata); + root.addChild(head); - let ttml = ` - - - - Lyrics - - - -
-`; + const body = new XmlNode("body"); + const div = new XmlNode("div"); for (const line of lines) { const lineStart = formatTime(line.startTime); const lineEnd = formatTime(line.endTime); + + const p = new XmlNode("p", { begin: lineStart, end: lineEnd }); - ttml += `

\n`; - - // 添加逐字歌词 for (const word of line.words) { - // 过滤无效的空词(内容为空且时长为0) - if (!word.word || word.startTime === word.endTime) { - continue; - } - const wordStart = formatTime(word.startTime); - const wordEnd = formatTime(word.endTime); - ttml += ` ${escapeXml(word.word)}\n`; + // 过滤无效的空词(内容为空且时长为0) + if (!word.word || word.startTime === word.endTime) continue; + + const wordStart = formatTime(word.startTime); + const wordEnd = formatTime(word.endTime); + p.addChild(new XmlNode("span", { begin: wordStart, end: wordEnd }).addChild(word.word)); } - // 添加翻译 if (line.translatedLyric) { - ttml += ` ${escapeXml(line.translatedLyric)}\n`; + p.addChild(new XmlNode("span", { "ttm:role": "x-translation" }).addChild(line.translatedLyric)); } - // 添加音译 if (line.romanLyric) { - ttml += ` ${escapeXml(line.romanLyric)}\n`; + p.addChild(new XmlNode("span", { "ttm:role": "x-roman" }).addChild(line.romanLyric)); } - - ttml += `

\n`; + + div.addChild(p); } - ttml += `
- -
`; + body.addChild(div); + root.addChild(body); - return ttml; + return `\n` + root.toString(); }; diff --git a/src/views/Download/downloading.vue b/src/views/Download/downloading.vue index 79041c768..b4f5da6fc 100644 --- a/src/views/Download/downloading.vue +++ b/src/views/Download/downloading.vue @@ -23,7 +23,7 @@
- +
{{ item.song.name }} @@ -100,6 +100,7 @@