Skip to content

Commit 8a930d5

Browse files
committed
✨ feat: 新增优先使用 QQ Music 歌词
1 parent 739dc23 commit 8a930d5

4 files changed

Lines changed: 266 additions & 5 deletions

File tree

src/api/qqmusic.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import request from "@/utils/request";
2+
3+
/**
4+
* QQ 音乐模糊匹配歌词响应
5+
*/
6+
export interface QQMusicMatchResponse {
7+
code: number;
8+
song?: {
9+
id: string;
10+
mid: string;
11+
name: string;
12+
artist: string;
13+
album: string;
14+
duration: number;
15+
};
16+
/** LRC 格式歌词 */
17+
lrc?: string;
18+
/** QRC 逐字歌词原始内容 */
19+
qrc?: string;
20+
/** 翻译歌词 */
21+
trans?: string;
22+
/** 罗马音歌词 */
23+
roma?: string;
24+
message?: string;
25+
}
26+
27+
/**
28+
* QQ 音乐模糊匹配获取歌词
29+
* @param keyword 搜索关键词(建议格式:歌曲名-歌手名)
30+
* @returns 歌词数据
31+
*/
32+
export const qqMusicMatch = (keyword: string): Promise<QQMusicMatchResponse> => {
33+
return request({
34+
baseURL: "/api/qqmusic",
35+
url: "/match",
36+
params: { keyword },
37+
});
38+
};
39+
40+
/**
41+
* QQ 音乐搜索歌曲
42+
* @param keyword 搜索关键词
43+
* @param page 页码
44+
* @param pageSize 每页数量
45+
*/
46+
export const qqMusicSearch = (keyword: string, page = 1, pageSize = 20) => {
47+
return request({
48+
baseURL: "/api/qqmusic",
49+
url: "/search",
50+
params: { keyword, page, pageSize },
51+
});
52+
};
53+
54+
/**
55+
* QQ 音乐获取歌词
56+
* @param id 歌曲 ID(数字)
57+
* @param name 歌曲名称
58+
* @param artist 歌手名称
59+
*/
60+
export const qqMusicLyric = (id: number, name?: string, artist?: string) => {
61+
return request({
62+
baseURL: "/api/qqmusic",
63+
url: "/lyric",
64+
params: { id, name, artist },
65+
});
66+
};

src/components/Setting/LyricsSetting.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@
205205
<n-switch v-model:value="settingStore.showYrc" class="set" :round="false" />
206206
</n-card>
207207
<n-collapse-transition :show="settingStore.showYrc">
208+
<n-card class="set-item">
209+
<div class="label">
210+
<n-text class="name">优先使用 QQ 音乐歌词</n-text>
211+
<n-text class="tip" :depth="3">
212+
优先从 QQ 音乐获取逐字歌词,模糊搜索,可能不准确
213+
</n-text>
214+
</div>
215+
<n-switch v-model:value="settingStore.preferQQMusicLyric" class="set" :round="false" />
216+
</n-card>
208217
<n-card class="set-item">
209218
<div class="label">
210219
<n-text class="name">显示逐字歌词动画</n-text>

src/core/player/LyricManager.ts

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { songLyric, songLyricTTML } from "@/api/song";
2+
import { qqMusicMatch } from "@/api/qqmusic";
23
import { keywords as defaultKeywords, regexes as defaultRegexes } from "@/assets/data/exclude";
34
import { useCacheManager } from "@/core/resource/CacheManager";
45
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
@@ -42,12 +43,13 @@ class LyricManager {
4243
* @param type 缓存类型
4344
* @returns 缓存数据
4445
*/
45-
private async getRawLyricCache(id: number, type: "lrc" | "ttml"): Promise<string | null> {
46+
private async getRawLyricCache(id: number, type: "lrc" | "ttml" | "qrc"): Promise<string | null> {
4647
const settingStore = useSettingStore();
4748
if (!isElectron || !settingStore.cacheEnabled) return null;
4849
try {
4950
const cacheManager = useCacheManager();
50-
const result = await cacheManager.get("lyrics", `${id}.${type === "ttml" ? "ttml" : "json"}`);
51+
const ext = type === "ttml" ? "ttml" : type === "qrc" ? "qrc.json" : "json";
52+
const result = await cacheManager.get("lyrics", `${id}.${ext}`);
5153
if (result.success && result.data) {
5254
// Uint8Array to string
5355
const decoder = new TextDecoder();
@@ -65,12 +67,13 @@ class LyricManager {
6567
* @param type 缓存类型
6668
* @param data 数据
6769
*/
68-
private async saveRawLyricCache(id: number, type: "lrc" | "ttml", data: string) {
70+
private async saveRawLyricCache(id: number, type: "lrc" | "ttml" | "qrc", data: string) {
6971
const settingStore = useSettingStore();
7072
if (!isElectron || !settingStore.cacheEnabled) return;
7173
try {
7274
const cacheManager = useCacheManager();
73-
await cacheManager.set("lyrics", `${id}.${type === "ttml" ? "ttml" : "json"}`, data);
75+
const ext = type === "ttml" ? "ttml" : type === "qrc" ? "qrc.json" : "json";
76+
await cacheManager.set("lyrics", `${id}.${ext}`, data);
7477
} catch (error) {
7578
console.error("写入歌词缓存失败:", error);
7679
}
@@ -143,6 +146,92 @@ class LyricManager {
143146
return { lrcData: aligned, yrcData: lyricData.yrcData };
144147
}
145148

149+
/**
150+
* 解析 QQ 音乐 QRC 格式歌词
151+
* @param qrcContent QRC 原始内容
152+
* @param trans 翻译歌词
153+
* @param roma 罗马音歌词
154+
* @returns LyricLine 数组
155+
*/
156+
private parseQRCLyric(qrcContent: string, trans?: string, roma?: string): LyricLine[] {
157+
const lines: LyricLine[] = [];
158+
// 感觉 QQ 音乐歌词时间全部有些慢,所以时间偏移一下
159+
const QRC_TIME_OFFSET = -200;
160+
161+
// 从 XML 中提取歌词内容
162+
const contentMatch = /<Lyric_1[^>]*LyricContent="([^"]*)"[^>]*\/>/.exec(qrcContent);
163+
const content = contentMatch ? contentMatch[1] : qrcContent;
164+
165+
// 行匹配: [开始时间,持续时间]内容
166+
const linePattern = /^\[(\d+),(\d+)\](.*)$/;
167+
// 逐字匹配: 文字(开始时间,持续时间)
168+
const wordPattern = /([^(]*)\((\d+),(\d+)\)/g;
169+
170+
for (const rawLine of content.split("\n")) {
171+
const line = rawLine.trim();
172+
if (!line) continue;
173+
174+
// 跳过元数据标签 [ti:xxx] [ar:xxx] 等
175+
if (/^\[[a-z]+:/i.test(line)) continue;
176+
177+
const lineMatch = linePattern.exec(line);
178+
if (!lineMatch) continue;
179+
180+
const lineStart = parseInt(lineMatch[1], 10) + QRC_TIME_OFFSET;
181+
const lineDuration = parseInt(lineMatch[2], 10);
182+
const lineContent = lineMatch[3];
183+
184+
// 解析逐字
185+
const words: Array<{ word: string; startTime: number; endTime: number }> = [];
186+
let wordMatch: RegExpExecArray | null;
187+
const wordRegex = new RegExp(wordPattern.source, "g");
188+
189+
while ((wordMatch = wordRegex.exec(lineContent)) !== null) {
190+
const wordText = wordMatch[1];
191+
const wordStart = parseInt(wordMatch[2], 10) + QRC_TIME_OFFSET;
192+
const wordDuration = parseInt(wordMatch[3], 10);
193+
194+
if (wordText) {
195+
words.push({
196+
word: wordText,
197+
startTime: wordStart,
198+
endTime: wordStart + wordDuration,
199+
});
200+
}
201+
}
202+
203+
if (words.length > 0) {
204+
lines.push({
205+
words: words.map((w) => ({ ...w, romanWord: "" })),
206+
startTime: lineStart,
207+
endTime: lineStart + lineDuration,
208+
translatedLyric: "",
209+
romanLyric: "",
210+
isBG: false,
211+
isDuet: false,
212+
});
213+
}
214+
}
215+
216+
// 处理翻译歌词
217+
if (trans) {
218+
const transLines = parseLrc(trans);
219+
if (transLines?.length) {
220+
return this.alignLyrics(lines, transLines, "translatedLyric");
221+
}
222+
}
223+
224+
// 处理罗马音歌词
225+
if (roma) {
226+
const romaLines = parseLrc(roma);
227+
if (romaLines?.length) {
228+
return this.alignLyrics(lines, romaLines, "romanLyric");
229+
}
230+
}
231+
232+
return lines;
233+
}
234+
146235
/**
147236
* 处理在线歌词
148237
* @param id 歌曲 ID
@@ -158,11 +247,94 @@ class LyricManager {
158247
const result: SongLyric = { lrcData: [], yrcData: [] };
159248
// 是否采用了 TTML
160249
let ttmlAdopted = false;
250+
// 是否采用了 QQ 音乐歌词
251+
let qqMusicAdopted = false;
161252
// 过期判断
162253
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
254+
255+
// 处理 QQ 音乐歌词
256+
const adoptQQMusic = async () => {
257+
if (!settingStore.preferQQMusicLyric) return;
258+
const song = musicStore.playSong;
259+
if (!song) return;
260+
// 先检查缓存
261+
const cached = await this.getRawLyricCache(id, "qrc");
262+
let data: any = null;
263+
if (cached) {
264+
try {
265+
data = JSON.parse(cached);
266+
} catch {
267+
data = null;
268+
}
269+
}
270+
// 如果没有缓存,则请求 API
271+
if (!data) {
272+
// 构建搜索关键词
273+
const artistsStr = Array.isArray(song.artists)
274+
? song.artists.map((a) => a.name).join("/")
275+
: song.artists || "";
276+
const keyword = `${song.name}-${artistsStr}`;
277+
try {
278+
data = await qqMusicMatch(keyword);
279+
} catch (error) {
280+
console.warn("QQ 音乐歌词获取失败:", error);
281+
return;
282+
}
283+
}
284+
if (isStale()) return;
285+
if (!data || data.code !== 200) return;
286+
287+
// 验证时长匹配(相差超过 5 秒视为不匹配)
288+
if (data.song?.duration) {
289+
const durationDiff = Math.abs(data.song.duration - song.duration);
290+
if (durationDiff > 5000) {
291+
console.warn(
292+
`QQ 音乐歌词时长不匹配: ${data.song.duration}ms vs ${song.duration}ms (差异 ${durationDiff}ms)`,
293+
);
294+
return;
295+
}
296+
}
297+
// 保存到缓存
298+
if (!cached && data.code === 200) {
299+
this.saveRawLyricCache(id, "qrc", JSON.stringify(data));
300+
}
301+
// 解析 QRC 逐字歌词
302+
if (data.qrc) {
303+
const qrcLines = this.parseQRCLyric(data.qrc, data.trans, data.roma);
304+
if (qrcLines.length > 0) {
305+
result.yrcData = qrcLines;
306+
qqMusicAdopted = true;
307+
}
308+
}
309+
// 解析 LRC 歌词(如果没有 QRC)
310+
if (!qqMusicAdopted && data.lrc) {
311+
let lrcLines = parseLrc(data.lrc) || [];
312+
// 处理翻译
313+
if (data.trans) {
314+
const transLines = parseLrc(data.trans);
315+
if (transLines?.length) {
316+
lrcLines = this.alignLyrics(lrcLines, transLines, "translatedLyric");
317+
}
318+
}
319+
// 处理罗马音
320+
if (data.roma) {
321+
const romaLines = parseLrc(data.roma);
322+
if (romaLines?.length) {
323+
lrcLines = this.alignLyrics(lrcLines, romaLines, "romanLyric");
324+
}
325+
}
326+
if (lrcLines.length > 0) {
327+
result.lrcData = lrcLines;
328+
qqMusicAdopted = true;
329+
}
330+
}
331+
};
332+
163333
// 处理 TTML 歌词
164334
const adoptTTML = async () => {
165335
if (!settingStore.enableOnlineTTMLLyric) return;
336+
// 如果已经有 QQ 音乐歌词,跳过 TTML
337+
if (qqMusicAdopted) return;
166338
let ttmlContent: string | null = await this.getRawLyricCache(id, "ttml");
167339
if (!ttmlContent) {
168340
ttmlContent = await songLyricTTML(id);
@@ -180,6 +352,8 @@ class LyricManager {
180352
};
181353
// 处理 LRC 歌词
182354
const adoptLRC = async () => {
355+
// 如果已经有 QQ 音乐歌词,跳过网易云
356+
if (qqMusicAdopted) return;
183357
let data: any = null;
184358
const cached = await this.getRawLyricCache(id, "lrc");
185359
if (cached) {
@@ -228,7 +402,16 @@ class LyricManager {
228402
const lyricData = this.handleLyricExclude(result);
229403
this.setFinalLyric(lyricData, req);
230404
};
231-
// 设置 TTML
405+
406+
// 优先获取 QQ 音乐歌词
407+
if (settingStore.preferQQMusicLyric) {
408+
await adoptQQMusic();
409+
}
410+
if (qqMusicAdopted) {
411+
statusStore.usingTTMLLyric = false;
412+
return result;
413+
}
414+
// 否则使用原有逻辑
232415
await Promise.allSettled([adoptTTML(), adoptLRC()]);
233416
statusStore.usingTTMLLyric = ttmlAdopted;
234417
return result;

src/stores/setting.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export interface SettingState {
179179
lyricOffsetStep: number;
180180
/** 是否启用在线 TTML 歌词 */
181181
enableOnlineTTMLLyric: boolean;
182+
/** 优先使用 QQ 音乐歌词源 */
183+
preferQQMusicLyric: boolean;
182184
/** AMLL DB 服务地址 */
183185
amllDbServer: string;
184186
/** 菜单显示封面 */
@@ -368,6 +370,7 @@ export const useSettingStore = defineStore("setting", {
368370
wordFadeWidth: 0.5,
369371
lyricOffsetStep: 500,
370372
enableOnlineTTMLLyric: false,
373+
preferQQMusicLyric: false,
371374
amllDbServer: defaultAMLLDbServer,
372375
showYrc: true,
373376
showYrcAnimation: true,

0 commit comments

Comments
 (0)